From 5f7155678d9fde379314421abb8995232f580c59 Mon Sep 17 00:00:00 2001 From: sarangs1621 Date: Sat, 13 Jun 2026 01:28:19 +0530 Subject: [PATCH 1/2] docs: add Phase 13 portfolio documentation Rewrite README as a portfolio-ready overview (features, architecture, tech stack, local setup, API docs) and add docs/ with the system architecture, database ER diagram, full API reference, deployment guide, interview-prep design-decision notes, resume bullets, and a screenshot checklist for portfolio assets. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 487 +++++++++++-------------------------- docs/API.md | 148 +++++++++++ docs/ARCHITECTURE.md | 170 +++++++++++++ docs/DEPLOYMENT.md | 148 +++++++++++ docs/ER_DIAGRAM.md | 203 ++++++++++++++++ docs/INTERVIEW_NOTES.md | 184 ++++++++++++++ docs/RESUME_BULLETS.md | 87 +++++++ docs/screenshots/README.md | 29 +++ 8 files changed, 1114 insertions(+), 342 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/ER_DIAGRAM.md create mode 100644 docs/INTERVIEW_NOTES.md create mode 100644 docs/RESUME_BULLETS.md create mode 100644 docs/screenshots/README.md diff --git a/README.md b/README.md index cfc4f6c..499b365 100644 --- a/README.md +++ b/README.md @@ -1,384 +1,187 @@ -# Sentinel — Phases 1-3, 5, 6 & 7: Auth, Workspaces, Monitors, Incidents, Scheduler, Alerting, Analytics & Audit Logging +# Sentinel -Production-grade observability/incident-management platform. Phase 1 -implements user registration/login (JWT) and workspace creation/joining with -owner/admin/member roles. Phase 2 adds monitor CRUD (HTTP/TCP/PING), RBAC -enforcement, soft delete, and workspace audit logging. Phase 3 (slice 1) -adds check-result recording, a per-monitor failure counter, and automatic -incident detection/resolution. Phase 3 (slice 2) adds a Celery + Redis -scheduler and worker that automatically run those checks. Phase 5 adds -per-workspace alert rules (webhook or email channels, each with their own -enabled flag and minimum severity) and async notification delivery for -incident open/resolve events. Phase 6 adds latency and uptime/SLA reporting, -persisted daily metric snapshots, a workspace dashboard, and a scheduled -daily aggregation job. Phase 7 enriches the audit log with before/after -diffs, request metadata (IP/user agent), and search/filtering APIs so that -admins/owners can trace who changed what, when, and from where. +[![CI](https://github.com/sarangs1621/Sentinel/actions/workflows/ci.yml/badge.svg)](https://github.com/sarangs1621/Sentinel/actions/workflows/ci.yml) -## Stack +Sentinel is a production-grade observability and incident management +platform built using **FastAPI**, **PostgreSQL**, **Redis**, and **Celery**. -FastAPI · SQLAlchemy 2.0 (async) · PostgreSQL · Alembic · Pydantic v2 · -Celery + Redis · Repository + Service layers · Pytest +It lets a team register HTTP/TCP/PING monitors for the services they care +about, automatically probes them on a schedule, opens and resolves +incidents based on a configurable failure threshold, fans alerts out to +webhook/email channels, and reports latency/uptime SLAs — all scoped to +per-workspace tenants with role-based access control and a full audit +trail. -## Local development +## Documentation -1. Copy the environment template and adjust as needed: - - ```bash - cp .env.example .env - ``` +| Doc | Contents | +|---|---| +| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | System diagram, background-job pipeline (Monitoring → Incident → Notification → Analytics), middleware stack, code layout | +| [`docs/ER_DIAGRAM.md`](docs/ER_DIAGRAM.md) | Full database schema as a Mermaid ER diagram, with design-decision notes | +| [`docs/API.md`](docs/API.md) | Full REST API reference, grouped by resource | +| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Environment variables, Docker setup, migrations, worker startup, health checks, troubleshooting | +| [`docs/INTERVIEW_NOTES.md`](docs/INTERVIEW_NOTES.md) | "Why X?" design-decision write-ups (Postgres, Redis, Celery, multi-tenancy, RBAC, thresholds, caching, audit logs) | +| [`docs/RESUME_BULLETS.md`](docs/RESUME_BULLETS.md) | Resume-ready bullet points describing this project | + +## Features + +- **Authentication** — JWT access/refresh tokens with refresh-token + rotation and revocation, Redis-backed access-token denylist (logout), + account lockout after repeated failed logins, and per-workspace API keys + for service-to-service access. +- **RBAC** — every workspace member has a role (`owner` / `admin` / + `member`); endpoints enforce role checks via FastAPI dependencies. +- **Workspace Management** — multi-tenant workspaces with invite-code + joining, member role management, and self-leave. +- **Monitor Management** — CRUD for HTTP/TCP/PING monitors with per-type + target validation, duplicate detection, and soft delete. +- **Monitoring Engine** — Celery-beat-driven scheduler dispatches due + checks; a worker probes each target (HTTP GET, TCP connect, OS ping) and + records the result. +- **Incident Management** — a configurable `failure_threshold` per monitor + drives automatic incident open/resolve, plus manual + acknowledge/resolve by admins/owners. +- **Alerting** — per-workspace alert rules (webhook or email, + severity-filtered) with async, retrying notification delivery. +- **Analytics** — on-demand latency percentiles (p50/p95/p99) and + check-based + time-based uptime/SLA reporting, plus a daily aggregation + job that persists historical `MetricSnapshot`s. +- **Audit Logs** — immutable, searchable log of every mutation with + before/after diffs, IP address, and user agent; sensitive fields are + redacted automatically. +- **Caching** — Redis-backed read-through cache for hot endpoints with a + fail-open degradation path if Redis is unavailable. +- **Rate Limiting** — fixed-window limiter (per-user or per-IP), with + stricter limits on auth endpoints, plus security-headers middleware + (CSP, HSTS, X-Frame-Options, request-size limits). +- **CI/CD** — GitHub Actions pipeline with lint (ruff), type checking + (mypy), tests + coverage gate (pytest, 95% minimum), Docker build + validation, and security scanning (pip-audit, detect-secrets), with + branch protection on `main`. + +## Architecture Overview -2. Start Postgres (and the API) with Docker Compose: +``` +Client ──HTTPS──> FastAPI ──SQLAlchemy (async)──> PostgreSQL + │ + ├──cache / rate-limit / denylist──> Redis + │ + └──enqueue──> Redis ──> Celery Beat + Workers + │ + Monitoring Engine (HTTP/TCP/PING checks) + │ + Incident Engine + (failure threshold, open/resolve) + │ + Notification Engine + (webhook / email delivery) + + Analytics Engine ──> daily MetricSnapshots + Audit Engine ──> immutable AuditLog rows +``` - ```bash - docker compose up -d - ``` +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full Mermaid +diagrams and component-by-component breakdown, and +[`docs/ER_DIAGRAM.md`](docs/ER_DIAGRAM.md) for the database schema. - The `api` container runs `alembic upgrade head` automatically on startup. +## Tech Stack -3. API docs are available at `http://localhost:8000/docs`. +| Layer | Technology | +|---|---| +| Backend | Python 3.12, FastAPI, Pydantic v2, Repository + Service layer architecture | +| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic migrations | +| Caching | Redis 7 (application cache, rate limiting, JWT denylist) | +| Workers | Celery (beat + worker), Redis broker/result backend | +| Infrastructure | Docker, multi-stage Dockerfile, Docker Compose (db/redis/api/worker/beat) | +| Testing | Pytest, pytest-asyncio, respx, 95% coverage gate | +| CI/CD | GitHub Actions (lint, type check, test+coverage, Docker build, security scan), branch protection | -### Running without Docker +## Local Setup -```bash -python -m venv .venv -. .venv/Scripts/activate # Windows -pip install -r requirements-dev.txt -alembic upgrade head -uvicorn app.main:app --reload -``` +### Requirements -### Local Postgres (no Docker, Windows) +- Docker + Docker Compose (recommended), **or** Python 3.12 + a local + PostgreSQL instance for running without Docker. -This machine has no Docker and the system `postgresql-x64-17` service (port -5432, runs as `NetworkService`) has an unknown superuser password. Local dev -instead uses a second, user-owned Postgres instance on port **5433**, with -data dir `C:\Users\saran\pgdata\sentinel`. `.env` is already configured for -this (`sentinel`/`sentinel` on `localhost:5433`, databases `sentinel` and -`sentinel_test`). +### Environment Variables -Start it: +Copy the template and adjust as needed: -```powershell -& "C:\Program Files\PostgreSQL\17\bin\pg_ctl.exe" -D "C:\Users\saran\pgdata\sentinel" -l "C:\Users\saran\pgdata\sentinel.log" -o "-p 5433" start +```bash +cp .env.example .env ``` -Stop it: +Key variables (see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) for the full +reference): -```powershell -& "C:\Program Files\PostgreSQL\17\bin\pg_ctl.exe" -D "C:\Users\saran\pgdata\sentinel" stop -``` +| Variable | Default | Purpose | +|---|---|---| +| `SECRET_KEY` | — (required) | JWT signing secret (≥32 chars outside `testing`) | +| `DATABASE_URL` | — (required) | `postgresql+asyncpg://...` connection string | +| `REDIS_URL` | `redis://localhost:6379/0` | Celery broker/result backend + cache | +| `CELERY_TASK_ALWAYS_EAGER` | `false` | `true` runs Celery tasks in-process (no broker needed) | +| `BACKEND_CORS_ORIGINS` | `[]` | Explicit allow-list of CORS origins (never `*`) | +| `CHECK_DISPATCH_INTERVAL_SECONDS` | `10` | How often Beat looks for due monitor checks | +| `RATE_LIMIT_ENABLED` | `true` | Toggle the rate-limit middleware | -## Database migrations +### Docker Commands ```bash -alembic revision --autogenerate -m "description" -alembic upgrade head +docker compose up -d ``` -## Tests +This starts `db` (Postgres), `redis`, `api`, `worker`, and `beat`. The +`api` container runs `alembic upgrade head` automatically before starting +`uvicorn`. -Tests run against `TEST_DATABASE_URL` (a separate database from the dev DB). +### Run Instructions (without Docker) ```bash -createdb sentinel_test # or via psql/pgAdmin -pytest -v +python -m venv .venv +. .venv/Scripts/activate # Windows +pip install -r requirements-dev.txt +alembic upgrade head +uvicorn app.main:app --reload ``` -## Coverage - -Every `pytest` run reports line+branch coverage for `app/` (configured via -`pytest.ini` and `pyproject.toml`) and fails if overall coverage drops below -**95%**: +Run the worker and beat scheduler in separate terminals (requires Redis, +`CELERY_TASK_ALWAYS_EAGER=false`): ```bash -pytest --cov=app --cov-report=html +celery -A app.core.celery_app worker --loglevel=info +celery -A app.core.celery_app beat --loglevel=info ``` -A detailed HTML report is written to `htmlcov/index.html` — open it in a -browser to see exactly which lines/branches are missing per file. The -`concurrency = ["greenlet", "thread"]` setting in `pyproject.toml` is required -for accurate measurement, since SQLAlchemy's async ORM calls run user code -inside `greenlet_spawn` and Celery workers spin up per-task event loops in -separate threads — without it, lines that run on every request can be -misreported as uncovered. - -## CI/CD - -Every push and pull request to `main` runs `.github/workflows/ci.yml`, with -five independent jobs: - -| Job | What it checks | -|---|---| -| `Lint (ruff)` | `ruff check .` | -| `Type check (mypy)` | `mypy app` | -| `Test & coverage` | `pytest` against a Postgres 16 service container, gated at 95% coverage (see [Coverage](#coverage)) | -| `Docker build validation` | `docker build` of the production image + `docker compose config` validation | -| `Security checks` | `pip-audit` dependency vulnerability scan + `detect-secrets` scan against `.secrets.baseline` | - -The `Test & coverage` job uploads the JUnit test report (`test-results`) and -HTML coverage report (`coverage-report`) as workflow artifacts; the -`Docker build validation` job uploads its build log (`docker-build-log`). - -### Branch protection - -To require these checks (and a review) before merging into `main`, add a -branch protection rule on the GitHub repo: - -1. Repo **Settings → Branches → Add branch protection rule**. -2. Branch name pattern: `main`. -3. Enable **Require a pull request before merging** and **Require approvals** - (at least 1). -4. Enable **Require status checks to pass before merging**, then select - `Lint (ruff)`, `Type check (mypy)`, `Test & coverage`, - `Docker build validation`, and `Security checks`. -5. Enable **Require branches to be up to date before merging**. - -### Updating the secrets baseline - -If `detect-secrets` flags a new finding that is a false positive (e.g. a test -fixture credential), regenerate the baseline locally and commit it: +### Tests ```bash -detect-secrets scan > .secrets.baseline +createdb sentinel_test # or via psql/pgAdmin +pytest -v ``` -### Known issue: pre-existing dependency vulnerabilities - -`pip-audit` currently reports 4 known vulnerabilities, so the `Security -checks` job fails on `main` until these are addressed in a dedicated pass: - -- `pytest==8.3.4` — fix is `9.0.3` (major version bump; needs a compatibility - pass with `pytest-asyncio`/`pytest-cov`/`respx` before upgrading). -- `starlette==0.41.3` (transitive, via `fastapi==0.115.6`) — 3 CVEs, fixes - require `starlette>=0.47`/`1.0.1`, which likely means a `fastapi` major - bump and a full regression pass of the custom middleware stack - (`app/core/security_headers.py`, `app/core/rate_limit.py`, CORS config). +## API Documentation -Tracked as a follow-up; not addressed as part of the CI/CD setup to avoid -bundling a risky framework upgrade with the pipeline rollout. +Once the app is running: -## API overview (Phase 1) +- **Swagger UI**: http://localhost:8000/docs +- **OpenAPI schema**: http://localhost:8000/api/v1/openapi.json +- **Health check**: http://localhost:8000/health -| Method & Path | Auth | Description | -|---|---|---| -| POST `/api/v1/auth/register` | – | Create a user account | -| POST `/api/v1/auth/login` | – | OAuth2 password login → access + refresh token | -| POST `/api/v1/auth/refresh` | – | Rotate a refresh token for a new token pair | -| POST `/api/v1/auth/logout` | – | Revoke a refresh token | -| GET `/api/v1/users/me` | user | Current user profile | -| POST `/api/v1/workspaces` | user | Create a workspace (creator becomes owner) | -| GET `/api/v1/workspaces` | user | List workspaces the user belongs to | -| POST `/api/v1/workspaces/join` | user | Join a workspace via invite code | -| GET `/api/v1/workspaces/{id}` | member | Workspace detail | -| PATCH `/api/v1/workspaces/{id}` | admin/owner | Update workspace | -| DELETE `/api/v1/workspaces/{id}` | owner | Delete workspace | -| POST `/api/v1/workspaces/{id}/invite-code/regenerate` | admin/owner | Rotate invite code | -| GET `/api/v1/workspaces/{id}/members` | member | List members | -| PATCH `/api/v1/workspaces/{id}/members/{user_id}` | admin/owner | Change member role | -| DELETE `/api/v1/workspaces/{id}/members/{user_id}` | admin/owner | Remove a member | -| DELETE `/api/v1/workspaces/{id}/members/me` | member | Leave workspace | - -## API overview (Phase 2) - -| Method & Path | Auth | Description | -|---|---|---| -| POST `/api/v1/workspaces/{id}/monitors` | member | Create a monitor (http/tcp/ping) | -| GET `/api/v1/workspaces/{id}/monitors` | member | List active monitors | -| GET `/api/v1/workspaces/{id}/monitors/{monitor_id}` | member | Monitor detail | -| PATCH `/api/v1/workspaces/{id}/monitors/{monitor_id}` | admin/owner/creator | Update a monitor | -| DELETE `/api/v1/workspaces/{id}/monitors/{monitor_id}` | admin/owner/creator | Soft-delete a monitor | -| GET `/api/v1/workspaces/{id}/audit-logs` | admin/owner | List workspace audit log entries | - -Monitor targets are validated per type: HTTP requires an absolute -`http(s)://` URL, TCP requires `host:port`, and PING requires a bare -hostname or IP. Duplicate monitors (same type + target) within a workspace -are rejected with 409. Deleting a monitor sets `deleted_at` and excludes it -from list/get; every create/update/delete is recorded in the workspace's -audit log. - -## API overview (Phase 3, slice 1: reliability & incidents) - -| Method & Path | Auth | Description | -|---|---|---| -| POST `/api/v1/workspaces/{id}/monitors/{monitor_id}/checks` | member | Record a health check result (success/failure) | -| GET `/api/v1/workspaces/{id}/monitors/{monitor_id}/checks` | member | List a monitor's check history | -| GET `/api/v1/workspaces/{id}/incidents` | member | List workspace incidents | -| GET `/api/v1/workspaces/{id}/incidents/{incident_id}` | member | Incident detail | -| PATCH `/api/v1/workspaces/{id}/incidents/{incident_id}` | admin/owner | Acknowledge (`investigating`) or manually `resolve` an incident | - -Each monitor has a configurable `failure_threshold` (default 3). Recording -a `failure` check increments the monitor's `consecutive_failures`; once it -reaches `failure_threshold`, the monitor is marked `down` and an incident -(severity `major`, status `open`) is opened — unless one is already open for -that monitor. Recording a `success` check resets the counter, marks the -monitor `up`, and auto-resolves any open incident. Admins/owners can also -acknowledge (`investigating`) or manually `resolve` an incident; resolved -incidents cannot be transitioned further. - -This slice has no scheduler yet — check results must be submitted via the -API above. The scheduler/Celery/Redis execution layer that performs checks -automatically is implemented in slice 2, below. - -## Phase 3, slice 2: scheduler & health-check worker - -A Celery beat schedule periodically dispatches a check for every active, -non-deleted monitor whose `check_interval_seconds` has elapsed since -`last_checked_at` (or that has never been checked). Each check runs in a -Celery worker task and performs the actual probe: - -- **HTTP**: `GET` the target URL; any response under 400 is `success`, - 4xx/5xx or a connection error is `failure`. -- **TCP**: open a TCP connection to `host:port`; success/failure based on - whether the connection completes. -- **PING**: run the OS `ping` command (one packet) against the target host. - -The result (status, response time, error message) is fed into -`CheckService.record_check`, which drives the same failure-counter and -incident lifecycle described above. - -### Running the scheduler +For a full endpoint reference grouped by resource (auth, workspaces, +monitors, checks, incidents, alert rules, notifications, metrics, audit +logs, API keys), see [`docs/API.md`](docs/API.md). -```bash -# Worker (executes checks) -celery -A app.core.celery_app worker --loglevel=info +## Portfolio Assets -# Beat (dispatches due checks on a schedule) -celery -A app.core.celery_app beat --loglevel=info -``` +- **Architecture diagram** — [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) + (Mermaid, renders on GitHub) +- **Database ER diagram** — [`docs/ER_DIAGRAM.md`](docs/ER_DIAGRAM.md) + (Mermaid, renders on GitHub) +- **Swagger UI** — ![Swagger UI](docs/screenshots/swagger-ui.png) +- **Sample API call** — ![API response](docs/screenshots/api-response.png) +- **Local deployment** — ![Docker Compose](docs/screenshots/docker-compose.png) +- **CI/CD pipeline** — ![CI pipeline](docs/screenshots/ci-pipeline.png) -Both require Redis (`REDIS_URL`, used as the Celery broker and result -backend) and `CELERY_TASK_ALWAYS_EAGER=false`. With Docker Compose, `redis`, -`worker`, and `beat` services are started alongside `db` and `api`. - -This machine has no Redis available, so local dev/tests set -`CELERY_TASK_ALWAYS_EAGER=true` in `.env` — Celery tasks run synchronously -in-process rather than via a broker. The dispatch interval is configurable -via `CHECK_DISPATCH_INTERVAL_SECONDS` (default 10s). - -## API overview (Phase 5: notification & alerting engine) - -| Method & Path | Auth | Description | -|---|---|---| -| POST `/api/v1/workspaces/{id}/alert-rules` | admin/owner | Create an alert rule (`name`, `channel_type`, `target`, `is_enabled`, `min_severity`) | -| GET `/api/v1/workspaces/{id}/alert-rules` | admin/owner | List alert rules for the workspace | -| GET `/api/v1/workspaces/{id}/alert-rules/{rule_id}` | admin/owner | Get a single alert rule | -| PATCH `/api/v1/workspaces/{id}/alert-rules/{rule_id}` | admin/owner | Update an alert rule | -| DELETE `/api/v1/workspaces/{id}/alert-rules/{rule_id}` | admin/owner | Delete an alert rule | -| GET `/api/v1/workspaces/{id}/notifications` | member | List notifications sent/queued for the workspace | -| GET `/api/v1/workspaces/{id}/notifications/{notification_id}` | member | Notification detail | - -Each workspace can configure any number of alert rules. Each rule has a -`channel_type` (`webhook` or `email`), a `target` (a webhook URL or an email -address, validated accordingly), an `is_enabled` flag, and an optional -`min_severity` (`minor`/`major`/`critical`) that filters out lower-severity -incidents. When an incident is **opened** (failure-threshold breach) or -**resolved** (auto-recovery or manual resolve), -`NotificationService.evaluate_incident_event` fans out to every enabled rule -whose `min_severity` the incident meets, queuing one `Notification` row per -rule (`status=pending`). - -A Celery beat schedule (`dispatch-pending-notifications`, same interval as -`CHECK_DISPATCH_INTERVAL_SECONDS`) finds notifications that are `pending` or -`failed` with `attempts < NOTIFICATION_MAX_ATTEMPTS` (5) and queues a -`deliver_notification` task for each. That task looks up the notification's -`AlertRule` and dispatches by `channel_type`: - -- `webhook` — POSTs a JSON payload (`{"event": ..., "incident": {...}}`) to - the rule's `target` URL via `httpx`. -- `email` — sends a plaintext email to the rule's `target` address via - `aiosmtplib`, configured through `SMTP_HOST`/`SMTP_PORT`/`SMTP_USERNAME`/ - `SMTP_PASSWORD`/`SMTP_USE_TLS`/`SMTP_FROM_ADDRESS`. - -Either way, the task records `attempts`, `last_attempted_at`, -`response_status_code` (webhook only), `error_message`, and `status` (`sent` -on success, `failed` otherwise), and writes a `notification.sent` or -`notification.failed` audit log entry. `alert_rule.created`, -`alert_rule.updated`, and `alert_rule.deleted` audit events are recorded for -rule changes. - -This machine has no SMTP server configured, so `deliver_email` will fail -with a connection error unless `SMTP_*` settings point at a real server; -tests mock `aiosmtplib.send` directly. - -## API overview (Phase 6: analytics & reporting engine) - -| Method & Path | Auth | Description | -|---|---|---| -| GET `/api/v1/workspaces/{id}/monitors/{monitor_id}/metrics/latency` | member | Latency stats (avg/min/max/p50/p95/p99 response time, ms) over a time range | -| GET `/api/v1/workspaces/{id}/monitors/{monitor_id}/metrics/uptime` | member | Uptime/SLA report (check-based pass ratio + time-based uptime %) over a time range | -| GET `/api/v1/workspaces/{id}/monitors/{monitor_id}/metrics/snapshots` | member | List persisted daily `MetricSnapshot` rows, optionally filtered by `start`/`end` date | -| GET `/api/v1/workspaces/{id}/dashboard` | member | Workspace-wide monitor/incident status counts and trailing-24h check stats | - -`latency` and `uptime` accept optional `start`/`end` ISO-8601 query params; -if either is omitted, both default to the trailing 24 hours. `end` must be -after `start` and the range cannot exceed 90 days, or the request is -rejected with 422. - -`uptime` reports two complementary SLA measures over `[start, end)`: - -- **check-based** — `check_pass_ratio` is - `successful_checks / (successful_checks + failed_checks) * 100`. -- **time-based** — `uptime_percentage` is derived from - `total_downtime_seconds`, the summed overlap of the monitor's incidents' - `[created_at, resolved_at or now)` windows with `[start, end)`. - -### Scheduled daily aggregation - -A Celery beat schedule (`aggregate-daily-metrics`, interval -`METRICS_AGGREGATION_INTERVAL_SECONDS`, default 3600s) runs -`dispatch_metric_aggregation`, which queues an `aggregate_monitor_metrics` -task for every active, non-deleted monitor. Each task computes the previous -UTC day's check counts, latency stats, and uptime/downtime, then upserts a -`MetricSnapshot` row keyed on `(monitor_id, period_type, period_start)` — -re-running for the same monitor/day updates the existing row instead of -duplicating it. The `snapshots` endpoint reads these persisted rows, while -`latency`, `uptime`, and the dashboard compute live from `checks` and -`incidents`. - -## API overview (Phase 7: audit logging & compliance) - -| Method & Path | Auth | Description | -|---|---|---| -| GET `/api/v1/workspaces/{id}/audit-logs` | admin/owner | List all audit log entries for the workspace (newest first) | -| GET `/api/v1/workspaces/{id}/audit-logs/search` | admin/owner | Search/filter audit logs by `user_id`, `action`, `entity_type`, `start`/`end` date range, with `limit`/`offset` pagination | -| GET `/api/v1/workspaces/{id}/audit-logs/{audit_log_id}` | admin/owner | Get a single audit log entry by id | - -Every `AuditLogRead` record has: `id`, `workspace_id`, `user_id` (`null` for -system-generated events), `action`, `entity_type`, `entity_id`, `old_values`, -`new_values` (both JSON snapshots, either may be `null`), `ip_address`, -`user_agent`, and `created_at`. The audit log API is **read-only** — -`PATCH`/`DELETE` on any audit-log route return 405, since entries must remain -immutable for compliance purposes. - -The following actions are recorded automatically: - -- **Monitor**: `monitor.created`, `monitor.updated`, `monitor.deleted`, - and — when a PATCH toggles `is_active` — an additional - `monitor.enabled`/`monitor.disabled` event. -- **Incident**: `incident.created` and `incident.resolved` (system-generated, - `user_id` is `null`, `new_values.reason = "monitor_recovered"`) when the - failure-threshold/recovery lifecycle opens or auto-resolves an incident; - `incident.acknowledged` and `incident.resolved` - (`new_values.reason = "manual"`) when an admin/owner manually transitions - an incident. -- **Alert Rule**: `alert_rule.created`, `alert_rule.updated`, - `alert_rule.deleted`. -- **Notification**: `notification.sent` / `notification.failed`, recorded by - the delivery worker (`user_id` is `null`). -- **Workspace**: `workspace.updated` (diff of changed fields). -- **Membership**: `member.added` (on join, `entity_type="user"`, - `new_values={"role": ...}`) and `member.removed` (on admin-initiated - removal or self-leave, `old_values={"role": ...}`). - -For mutations made via the HTTP API, `ip_address` and `user_agent` are -captured from the request (`AuditContextDep` in `app/api/deps.py`). -System-generated events (auto incident open/resolve, notification delivery) -have `user_id`, `ip_address`, and `user_agent` all `null`. - -## Out of scope - -Slack, Discord, Microsoft Teams, and SMS notifications, plus AI-driven -analysis, forecasting, and anomaly detection, SIEM integrations, compliance -exports, and external log shipping — reserved for future phases. Phase 8 -(platform optimization & scalability) is next. +The four screenshots above are captured locally (not generated by this +repo) — see [`docs/screenshots/README.md`](docs/screenshots/README.md) for +the exact steps and filenames. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..0a52360 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,148 @@ +# API Reference + +Base path: `{API_V1_PREFIX}` (default `/api/v1`). Interactive docs at +`/docs` (Swagger UI) and `/api/v1/openapi.json` (OpenAPI schema). + +**Auth column legend:** +- `–` — no authentication required +- `user` — valid JWT access token or `X-API-Key` +- `member` / `admin/owner` / `owner` — workspace role required, in addition + to being authenticated (RBAC via `app/api/deps.py`) + +## Auth + +| Method & Path | Auth | Description | +|---|---|---| +| POST `/auth/register` | – | Create a user account | +| POST `/auth/login` | – | OAuth2 password login (`username` = email) → access + refresh token pair | +| POST `/auth/refresh` | – | Rotate a refresh token (`jti`-based) for a new token pair | +| POST `/auth/logout` | – | Revoke a single refresh token; if an access token is also presented, it's added to the Redis denylist | +| POST `/auth/logout-all` | user | Revoke **all** of the current user's refresh tokens and denylist the presented access token | + +## Users + +| Method & Path | Auth | Description | +|---|---|---| +| GET `/users/me` | user | Current user profile | + +## Workspaces & Members + +| Method & Path | Auth | Description | +|---|---|---| +| POST `/workspaces` | user | Create a workspace (creator becomes `owner`) | +| GET `/workspaces` | user | List workspaces the current user belongs to | +| POST `/workspaces/join` | user | Join a workspace via invite code (becomes `member`) | +| GET `/workspaces/{id}` | member | Workspace detail | +| PATCH `/workspaces/{id}` | admin/owner | Update workspace (name/description) | +| DELETE `/workspaces/{id}` | owner | Delete a workspace and all its data | +| POST `/workspaces/{id}/invite-code/regenerate` | admin/owner | Rotate the workspace invite code | +| GET `/workspaces/{id}/members` | member | List workspace members and roles | +| PATCH `/workspaces/{id}/members/{user_id}` | admin/owner | Change a member's role | +| DELETE `/workspaces/{id}/members/{user_id}` | admin/owner | Remove a member from the workspace | +| DELETE `/workspaces/{id}/members/me` | member | Leave the workspace | + +## Monitors + +| Method & Path | Auth | Description | +|---|---|---| +| POST `/workspaces/{id}/monitors` | member | Create a monitor (`monitor_type`: `http`/`tcp`/`ping`) | +| GET `/workspaces/{id}/monitors` | member | List active (non-deleted) monitors | +| GET `/workspaces/{id}/monitors/{monitor_id}` | member | Monitor detail (incl. `status`, `consecutive_failures`) | +| PATCH `/workspaces/{id}/monitors/{monitor_id}` | admin/owner/creator | Update a monitor | +| DELETE `/workspaces/{id}/monitors/{monitor_id}` | admin/owner/creator | Soft-delete a monitor (`deleted_at` set) | + +Target validation is per-type: HTTP requires an absolute `http(s)://` URL, +TCP requires `host:port`, PING requires a bare hostname/IP. Duplicate +monitors (same `monitor_type` + `target`) within a workspace return `409`. +Every create/update/delete is recorded in the audit log, including +`monitor.enabled`/`monitor.disabled` when a PATCH toggles `is_active`. + +## Checks + +| Method & Path | Auth | Description | +|---|---|---| +| POST `/workspaces/{id}/monitors/{monitor_id}/checks` | member | Record a check result (`success`/`failure`) — also used by the Celery worker | +| GET `/workspaces/{id}/monitors/{monitor_id}/checks` | member | List a monitor's check history (newest first) | + +Recording a `failure` increments `consecutive_failures`; reaching +`failure_threshold` (default 3) marks the monitor `down` and opens an +`Incident` (unless one is already open). A `success` resets the counter, +marks the monitor `up`, and auto-resolves any open incident. + +## Incidents + +| Method & Path | Auth | Description | +|---|---|---| +| GET `/workspaces/{id}/incidents` | member | List workspace incidents (newest first) | +| GET `/workspaces/{id}/incidents/{incident_id}` | member | Incident detail | +| PATCH `/workspaces/{id}/incidents/{incident_id}` | admin/owner | Acknowledge (`investigating`) or manually `resolve` | + +Resolved incidents cannot be transitioned further. Manual resolution sets +`new_values.reason = "manual"` in the audit log; automatic resolution sets +`reason = "monitor_recovered"`. + +## Alerting (Alert Rules & Notifications) + +| Method & Path | Auth | Description | +|---|---|---| +| POST `/workspaces/{id}/alert-rules` | admin/owner | Create an alert rule (`name`, `channel_type`: `webhook`/`email`, `target`, `is_enabled`, `min_severity`) | +| GET `/workspaces/{id}/alert-rules` | admin/owner | List alert rules | +| GET `/workspaces/{id}/alert-rules/{rule_id}` | admin/owner | Get a single alert rule | +| PATCH `/workspaces/{id}/alert-rules/{rule_id}` | admin/owner | Update an alert rule | +| DELETE `/workspaces/{id}/alert-rules/{rule_id}` | admin/owner | Delete an alert rule | +| GET `/workspaces/{id}/notifications` | member | List notifications sent/queued for the workspace | +| GET `/workspaces/{id}/notifications/{notification_id}` | member | Notification detail (status, attempts, error) | + +On incident open/resolve, `evaluate_incident_event` queues one +`Notification` (`status=pending`) per enabled `AlertRule` whose +`min_severity` the incident meets. A Beat job retries `pending`/`failed` +notifications (up to `NOTIFICATION_MAX_ATTEMPTS=5`), delivering via +`httpx` (webhook) or `aiosmtplib` (email) and recording +`attempts`/`last_attempted_at`/`response_status_code`/`error_message`/`status`. + +## Metrics & Dashboard + +| Method & Path | Auth | Description | +|---|---|---| +| GET `/workspaces/{id}/monitors/{monitor_id}/metrics/latency` | member | Latency stats (avg/min/max/p50/p95/p99 ms) over `start`/`end` | +| GET `/workspaces/{id}/monitors/{monitor_id}/metrics/uptime` | member | Uptime/SLA report (check-based + time-based) over `start`/`end` | +| GET `/workspaces/{id}/monitors/{monitor_id}/metrics/snapshots` | member | List persisted daily `MetricSnapshot`s, optional `start`/`end` filter | +| GET `/workspaces/{id}/dashboard` | member | Workspace-wide monitor/incident status counts + trailing-24h check stats | + +`latency`/`uptime` accept optional ISO-8601 `start`/`end` query params; if +either is omitted, both default to the trailing 24 hours. `end` must be +after `start` and the range cannot exceed 90 days (`422` otherwise). +`uptime_percentage` is time-based (downtime = overlap of incident windows +with `[start, end)`); `check_pass_ratio` is check-based +(`successful / (successful + failed) * 100`). + +## Audit Logs + +| Method & Path | Auth | Description | +|---|---|---| +| GET `/workspaces/{id}/audit-logs` | admin/owner | List all audit log entries (newest first) | +| GET `/workspaces/{id}/audit-logs/search` | admin/owner | Filter by `user_id`, `action`, `entity_type`, `start`/`end`, with `limit` (1-200, default 50) / `offset` pagination | +| GET `/workspaces/{id}/audit-logs/{audit_log_id}` | admin/owner | Get a single audit log entry | + +The audit log API is **read-only** — there are no `PATCH`/`DELETE` routes, +since entries must remain immutable for compliance. Each entry has +`old_values`/`new_values` JSON diffs, `ip_address`, `user_agent`, and +`user_id` (`null` for system-generated events such as auto incident +open/resolve and notification delivery). + +## API Keys + +| Method & Path | Auth | Description | +|---|---|---| +| POST `/workspaces/{id}/api-keys` | admin/owner | Create an API key — the **full key is returned only once** (response includes `ApiKeyCreated`, only `key_prefix` + `hashed_key` are stored) | +| GET `/workspaces/{id}/api-keys` | admin/owner | List API keys (prefix, name, `last_used_at`, `revoked_at`) | +| DELETE `/workspaces/{id}/api-keys/{api_key_id}` | admin/owner | Revoke an API key | + +API keys authenticate via the `X-API-Key` header as an alternative to a +JWT bearer token, scoped to the workspace that created them. + +## Health + +| Method & Path | Auth | Description | +|---|---|---| +| GET `/health` | – | Liveness check, returns `{"status": "ok"}` (not under `/api/v1`) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6bf3a9e --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,170 @@ +# Architecture + +Sentinel is a multi-tenant uptime-monitoring and incident-management API. +This document covers the system topology, the background-job pipeline that +turns a failed health check into a delivered alert, and the request +middleware stack. + +## 1. System overview + +```mermaid +graph TD + Client["Client (browser / curl / Swagger UI)"] + + subgraph API["FastAPI app (app/main.py)"] + MW["Middleware: CORS -> RateLimit -> SecurityHeaders"] + Routes["/api/v1/* routers"] + Services["Service layer"] + Repos["Repository layer"] + end + + Postgres[("PostgreSQL 16")] + Redis[("Redis 7")] + + subgraph Async["Background processing"] + Beat["Celery Beat (scheduler)"] + Worker["Celery Worker"] + end + + Client -->|"HTTPS + JWT or X-API-Key"| MW --> Routes --> Services --> Repos --> Postgres + Services -->|"cache / rate limit / denylist"| Redis + Beat -->|"broker"| Redis + Worker -->|"broker + result backend"| Redis + Redis -->|"dispatch"| Worker + Worker --> Repos +``` + +- **FastAPI app** (`app/main.py`) — the only synchronous entry point. + Registers three layers of middleware, the versioned API router, a + exception-to-HTTP-status mapping layer, and a `/health` endpoint. +- **PostgreSQL** — system of record for everything (`app/db`, SQLAlchemy 2.0 + async ORM, Alembic migrations). +- **Redis** — three independent roles: Celery broker/result backend, an + application cache (`app/services/cache.py`), and security state (rate + limiting, login-failure counters, JWT access-token denylist). All Redis + reads/writes catch `RedisError` and degrade gracefully — Sentinel keeps + serving correct (just less cached/rate-limited) responses if Redis is + down. +- **Celery Beat + Worker** — Beat enqueues three periodic jobs; Worker + executes them. In dev/test, `CELERY_TASK_ALWAYS_EAGER=true` runs the same + task functions synchronously in-process, so the business logic is + identical with or without a broker. + +## 2. Background pipeline: Monitoring -> Incident -> Notification -> Analytics + +This is the core value loop of the product — a scheduled probe that can end +with a human getting paged. + +```mermaid +graph LR + Beat["Celery Beat"] -->|"every CHECK_DISPATCH_INTERVAL_SECONDS"| Dispatch["dispatch_due_checks"] + Dispatch -->|"one task per due monitor"| Perform["perform_check"] + + subgraph ME["Monitoring Engine (app/workers/checkers.py)"] + Perform --> HTTP["_check_http (GET, <400 = success)"] + Perform --> TCP["_check_tcp (open_connection)"] + Perform --> PING["_check_ping (OS ping subprocess)"] + end + + HTTP --> Record["CheckService.record_check"] + TCP --> Record + PING --> Record + + subgraph IE["Incident Engine (app/services/incident.py + check.py)"] + Record -->|"failure: consecutive_failures++"| Threshold{"consecutive_failures\n>= failure_threshold?"} + Threshold -->|"yes, no open incident"| OpenInc["open Incident (severity=major)"] + Record -->|"success: reset counter"| Resolve{"open incident\nexists?"} + Resolve -->|"yes"| ResolveInc["auto-resolve Incident"] + end + + OpenInc --> Evaluate["NotificationService.evaluate_incident_event"] + ResolveInc --> Evaluate + + subgraph NE["Notification Engine (app/services/notification.py + workers/notifier.py)"] + Evaluate -->|"fan out to enabled AlertRules\nmatching min_severity"| Queue["queue Notification (pending)"] + BeatN["Celery Beat: dispatch-pending-notifications"] --> Deliver["deliver_notification"] + Queue --> Deliver + Deliver -->|"webhook"| Webhook["POST JSON via httpx"] + Deliver -->|"email"| Email["aiosmtplib send"] + end + + subgraph AE["Analytics Engine (app/services/metrics.py)"] + BeatA["Celery Beat: aggregate-daily-metrics"] --> Aggregate["aggregate_monitor_metrics"] + Aggregate -->|"upsert keyed on\n(monitor_id, period_type, period_start)"| Snapshot["MetricSnapshot"] + end + + OpenInc -.->|"audit event"| Audit["Audit Engine"] + ResolveInc -.->|"audit event"| Audit + Deliver -.->|"audit event"| Audit +``` + +- **Monitoring Engine** (`app/workers/checkers.py`) — three stateless probe + functions, each with a bounded timeout (`_CHECK_TIMEOUT_SECONDS = 10s`): + HTTP (status `< 400` = success), TCP (`asyncio.open_connection`), PING + (shells out to the OS `ping` binary, 1 packet). Returns a `CheckOutcome` + (status, response time, error message) — no side effects. +- **Incident Engine** (`app/services/check.py` + `app/services/incident.py`) + — `CheckService.record_check` persists the `Check` row, then applies the + failure-threshold state machine on the `Monitor`: failures increment + `consecutive_failures` and flip `status -> down` + open an `Incident` + once the threshold is reached; a success resets the counter, flips + `status -> up`, and auto-resolves any open incident. `IncidentService` + additionally supports manual `acknowledge`/`resolve` by admins/owners. +- **Notification Engine** (`app/services/notification.py` + + `app/workers/notifier.py`) — on every incident open/resolve, + `evaluate_incident_event` fans out to every `AlertRule` in the workspace + whose `is_enabled=true` and whose `min_severity` the incident meets, + queuing one `Notification` row per rule. A second Beat job picks up + `pending`/retryable-`failed` notifications and delivers them via webhook + (`httpx` POST) or email (`aiosmtplib`), recording attempts, status, and + any error. +- **Analytics Engine** (`app/services/metrics.py`) — computes live latency + percentiles (Postgres `percentile_cont` for p50/p95/p99) and uptime/SLA + (check-based pass ratio + time-based downtime from incident overlap) + on demand for any time range, and a daily Beat job persists a + `MetricSnapshot` per monitor for historical reporting without + re-scanning all `checks`. +- **Audit Engine** (`app/services/audit_log.py`) — every mutating endpoint + and every system-driven state transition (incident open/resolve, + notification sent/failed) writes an immutable `AuditLog` row with + before/after diffs (`old_values`/`new_values`), redacting any field that + looks like a secret (`password|secret|token|api_key|hashed_key|^key$`) + before it's stored. + +## 3. Request middleware stack + +```mermaid +graph TD + Req["Incoming request"] --> CORS["CORSMiddleware\n(explicit allow-list, never '*')"] + CORS --> RL["RateLimitMiddleware\n(fixed window, per-user or per-IP,\nstricter limits on /auth/*)"] + RL --> SH["SecurityHeadersMiddleware\n(413 if Content-Length too large,\nCSP/HSTS/X-Frame-Options etc.)"] + SH --> Auth["Auth dependency\n(JWT bearer or X-API-Key)"] + Auth --> Handler["Endpoint handler"] + Handler --> ExcMap["Domain exception -> HTTP status\n(NotFoundError->404, ConflictError->409,\nPermissionDeniedError->403, ValidationError->422,\nAuthenticationError->401, AccountLockedError->423)"] +``` + +Every domain exception (`app/core/exceptions.py`) is translated to an HTTP +response by a registered `@app.exception_handler`, so service-layer code +never imports FastAPI/Starlette types — services raise plain Python +exceptions and stay framework-agnostic and unit-testable. + +## 4. Code layout + +``` +app/ +├── api/v1/endpoints/ # FastAPI routers — request/response only +├── api/deps.py # auth, RBAC, audit-context dependencies +├── core/ # config, security, celery app, rate limiting, +│ # security headers, redis client, exceptions +├── models/ # SQLAlchemy 2.0 ORM models +├── schemas/ # Pydantic request/response models +├── repositories/ # data access (one per aggregate) +├── services/ # business logic (auth, monitor, check, +│ # incident, notification, alert_rule, metrics, +│ # audit_log, cache, api_key, workspace) +└── workers/ # Celery tasks, checkers, notifier, worker DB/Redis +``` + +The **Repository -> Service -> Endpoint** layering means business rules +(failure thresholds, RBAC, audit diffs) live in one place and are exercised +identically whether triggered via the HTTP API or a Celery task. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..e4b9cdf --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,148 @@ +# Deployment Guide + +## Environment Variables + +All configuration is read by `app/core/config.py` (Pydantic `Settings`), +sourced from a `.env` file (see `.env.example`) or real environment +variables. Validation runs at startup — the app refuses to boot with an +invalid configuration. + +| Variable | Default | Notes | +|---|---|---| +| `ENVIRONMENT` | `development` | `testing` relaxes the `SECRET_KEY` length check | +| `PROJECT_NAME` | `Sentinel` | Used as the FastAPI app title | +| `API_V1_PREFIX` | `/api/v1` | Prefix for all versioned routes and the OpenAPI schema | +| `BACKEND_CORS_ORIGINS` | `[]` | JSON list or comma-separated string of allowed origins. **Must not contain `"*"`** — validated and rejected at startup. CORS middleware is only registered if this is non-empty | +| `SECRET_KEY` | — (required) | JWT signing key. Must be ≥32 chars unless `ENVIRONMENT=testing`. Generate with `python -c "import secrets; print(secrets.token_urlsafe(64))"` | +| `JWT_ALGORITHM` | `HS256` | JWT signing algorithm | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access token lifetime | +| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | Refresh token lifetime; `refresh_tokens.id` doubles as the JWT `jti` | +| `DATABASE_URL` | — (required) | `postgresql+asyncpg://user:pass@host:port/db` | +| `TEST_DATABASE_URL` | `None` | Separate database used by the test suite | +| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | `sentinel` / `sentinel` / `sentinel` | Used by Docker Compose to provision the `db` service | +| `POSTGRES_HOST` / `POSTGRES_PORT` | `localhost` / `5432` | Used when constructing connection strings outside Docker | +| `REDIS_URL` | `redis://localhost:6379/0` | Celery broker + result backend, app cache, rate limiting, JWT denylist | +| `CELERY_TASK_ALWAYS_EAGER` | `false` | `true` runs Celery tasks synchronously in-process — useful for local dev/tests without Redis | +| `CHECK_DISPATCH_INTERVAL_SECONDS` | `10.0` | How often Beat dispatches `dispatch_due_checks` and `dispatch_pending_notifications` | +| `METRICS_AGGREGATION_INTERVAL_SECONDS` | `3600.0` | How often Beat dispatches `aggregate_monitor_metrics` | +| `RATE_LIMIT_ENABLED` | `true` | Toggle `RateLimitMiddleware` | +| `RATE_LIMIT_REQUESTS` / `RATE_LIMIT_WINDOW_SECONDS` | `100` / `60` | General fixed-window limiter | +| `AUTH_RATE_LIMIT_REQUESTS` / `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `5` / `60` | Stricter limiter for `/auth/login` and `/auth/register` | +| `MAX_LOGIN_FAILURES` / `LOGIN_LOCKOUT_WINDOW_SECONDS` | `5` / `900` | Account lockout after repeated failed logins for the same email | +| `MAX_REQUEST_BODY_BYTES` | `1048576` (1 MiB) | Requests over this size get `413` from `SecurityHeadersMiddleware` | +| `HSTS_MAX_AGE_SECONDS` | `63072000` (2 years) | `Strict-Transport-Security` header value | +| `SMTP_HOST` / `SMTP_PORT` | `localhost` / `587` | Email alert delivery via `aiosmtplib` | +| `SMTP_USERNAME` / `SMTP_PASSWORD` | `None` | SMTP auth (optional) | +| `SMTP_USE_TLS` | `true` | STARTTLS for SMTP | +| `SMTP_FROM_ADDRESS` | `alerts@sentinel.local` | `From` address for email alerts | + +## Docker Setup + +`docker-compose.yml` defines five services: + +| Service | Image / Build | Purpose | +|---|---|---| +| `db` | `postgres:16-alpine` | Primary database, healthcheck via `pg_isready` | +| `redis` | `redis:7-alpine` | Celery broker/result backend + app cache, healthcheck via `redis-cli ping` | +| `api` | built from `Dockerfile` | FastAPI app, runs `alembic upgrade head` then `uvicorn` | +| `worker` | built from `Dockerfile` | `celery -A app.core.celery_app worker --loglevel=info` | +| `beat` | built from `Dockerfile` | `celery -A app.core.celery_app beat --loglevel=info` | + +`api`, `worker`, and `beat` all load `.env` via `env_file`, then override +`DATABASE_URL` (pointing at the `db` service), `REDIS_URL` (pointing at the +`redis` service), and set `CELERY_TASK_ALWAYS_EAGER=false`. `api` and +`worker`/`beat` both wait for `db`/`redis` to report healthy before +starting. + +The `Dockerfile` is a two-stage build: + +1. **`builder`** (`python:3.12-slim`) — creates a venv at `/opt/venv` and + installs `requirements.txt`. +2. **`runtime`** (`python:3.12-slim`) — copies only the venv, + `alembic.ini`, `alembic/`, and `app/` into the image, runs as a + non-root `app` user, and exposes port `8000`. + +Start everything: + +```bash +cp .env.example .env +docker compose up -d +docker compose ps # check health status +docker compose logs -f api +``` + +## Database Migration Process + +Migrations are managed with Alembic (`alembic/versions/`). + +- **Apply migrations** (done automatically by the `api` container on + startup, and required before running locally without Docker): + + ```bash + alembic upgrade head + ``` + +- **Create a new migration** after changing a model in `app/models/`: + + ```bash + alembic revision --autogenerate -m "describe the change" + ``` + + Review the generated file in `alembic/versions/` before committing — + autogenerate doesn't always detect things like check constraints or + partial indexes correctly (Sentinel's soft-delete partial unique index + and the migration-0008 query indexes were hand-verified). + +- **Roll back one revision**: + + ```bash + alembic downgrade -1 + ``` + +## Worker Startup + +The Monitoring/Notification/Analytics engines run as Celery tasks, +scheduled by Celery Beat (`app/core/celery_app.py`, `beat_schedule`): + +| Periodic task | Interval | Does | +|---|---|---| +| `dispatch_due_checks` | `CHECK_DISPATCH_INTERVAL_SECONDS` (default 10s) | Queues `perform_check` for every active monitor whose `check_interval_seconds` has elapsed | +| `dispatch_pending_notifications` | `CHECK_DISPATCH_INTERVAL_SECONDS` | Queues `deliver_notification` for `pending`/retryable-`failed` notifications | +| `aggregate_monitor_metrics` (dispatcher) | `METRICS_AGGREGATION_INTERVAL_SECONDS` (default 3600s) | Queues a per-monitor aggregation task that upserts yesterday's `MetricSnapshot` | + +Start a worker and beat process (requires Redis and +`CELERY_TASK_ALWAYS_EAGER=false`): + +```bash +celery -A app.core.celery_app worker --loglevel=info +celery -A app.core.celery_app beat --loglevel=info +``` + +For local development/CI without Redis, set `CELERY_TASK_ALWAYS_EAGER=true` +— `.delay()`/`.apply_async()` calls execute the task function synchronously +in the calling process, so the same code path is exercised by tests without +a broker. + +## Health Checks + +- **App**: `GET /health` → `{"status": "ok"}`. Add this as a container/load + balancer health check for the `api` service. +- **Postgres** (`db` service): `pg_isready -U $POSTGRES_USER`, every 5s, + 5 retries. +- **Redis** (`redis` service): `redis-cli ping`, every 5s, 5 retries. +- **OpenAPI**: `GET /api/v1/openapi.json` returning `200` confirms the app + booted and registered all routers. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| App fails to start: `SECRET_KEY must be at least 32 characters...` | `SECRET_KEY` missing/short in `.env` | Generate a real secret: `python -c "import secrets; print(secrets.token_urlsafe(64))"` | +| App fails to start: `BACKEND_CORS_ORIGINS must not contain '*'` | `.env` has `BACKEND_CORS_ORIGINS=["*"]` | List explicit origins, e.g. `["http://localhost:3000"]` | +| CORS preflight missing `Access-Control-Allow-Origin` | `BACKEND_CORS_ORIGINS` is empty — `CORSMiddleware` is only added if the list is non-empty | Set `BACKEND_CORS_ORIGINS` to include your frontend origin | +| `alembic upgrade head` fails with connection refused | `DATABASE_URL` points at the wrong host/port, or Postgres isn't ready yet | In Docker, ensure `db` healthcheck passes first; locally, confirm Postgres is running on the configured port | +| Checks never run / monitors stay `pending` | `worker`/`beat` not running and `CELERY_TASK_ALWAYS_EAGER=false` | Start both Celery processes, or set `CELERY_TASK_ALWAYS_EAGER=true` for in-process execution | +| Webhook/email alerts never deliver | `worker`/`beat` not running, or `SMTP_*`/webhook target unreachable | Check `notifications` rows for `status=failed` + `error_message`; verify `SMTP_HOST`/credentials | +| Redis down but app still responds (just slower / unrate-limited) | Expected — cache, rate limiting, and the JWT denylist all fail open on `RedisError` | Restore Redis; no data is lost, but rate limiting and access-token revocation are temporarily inactive | +| `429 Too Many Requests` during local testing | `RATE_LIMIT_ENABLED=true` with default limits | Set `RATE_LIMIT_ENABLED=false` for local/dev/test environments (already set in CI) | +| Test suite hangs or fails to find a database | `TEST_DATABASE_URL` not set or test DB doesn't exist | `createdb sentinel_test`, set `TEST_DATABASE_URL` in `.env` | diff --git a/docs/ER_DIAGRAM.md b/docs/ER_DIAGRAM.md new file mode 100644 index 0000000..ec8ec15 --- /dev/null +++ b/docs/ER_DIAGRAM.md @@ -0,0 +1,203 @@ +# Database ER Diagram + +Sentinel's schema is managed by Alembic (`alembic/versions/`, 9 migrations) and +defined in `app/models/`. Every table uses a UUID primary key +(`UUIDPkMixin`) and `created_at`/`updated_at` timestamps +(`CreatedAtMixin`/`TimestampMixin`). + +```mermaid +erDiagram + USERS { + uuid id PK + string email + string hashed_password + string full_name + bool is_active + bool is_superuser + } + + WORKSPACES { + uuid id PK + string name + string slug + string description + string invite_code + } + + WORKSPACE_MEMBERS { + uuid id PK + uuid workspace_id FK + uuid user_id FK + enum role "owner | admin | member" + } + + MONITORS { + uuid id PK + uuid workspace_id FK + uuid created_by_user_id FK + string name + enum monitor_type "http | tcp | ping" + string target + int check_interval_seconds + int failure_threshold + int consecutive_failures + enum status "pending | up | down" + bool is_active + datetime deleted_at + } + + CHECKS { + uuid id PK + uuid monitor_id FK + enum status "success | failure" + int response_time_ms + string error_message + datetime created_at + } + + INCIDENTS { + uuid id PK + uuid workspace_id FK + uuid monitor_id FK + string title + enum status "open | investigating | resolved" + enum severity "minor | major | critical" + datetime resolved_at + } + + ALERT_RULES { + uuid id PK + uuid workspace_id FK + string name + enum channel_type "webhook | email" + string target + bool is_enabled + enum min_severity "minor | major | critical | null" + } + + NOTIFICATIONS { + uuid id PK + uuid workspace_id FK + uuid incident_id FK + uuid alert_rule_id FK + enum event_type "incident_opened | incident_resolved" + enum status "pending | sent | failed" + int attempts + datetime last_attempted_at + int response_status_code + string error_message + } + + AUDIT_LOGS { + uuid id PK + uuid workspace_id FK + uuid user_id FK + string action + string entity_type + uuid entity_id + jsonb old_values + jsonb new_values + string ip_address + string user_agent + datetime created_at + } + + API_KEYS { + uuid id PK + uuid workspace_id FK + uuid created_by_user_id FK + string name + string key_prefix + string hashed_key + datetime last_used_at + datetime revoked_at + } + + REFRESH_TOKENS { + uuid id PK "doubles as JWT jti" + uuid user_id FK + datetime expires_at + bool revoked + } + + METRIC_SNAPSHOTS { + uuid id PK + uuid monitor_id FK + uuid workspace_id FK + enum period_type "daily" + datetime period_start + datetime period_end + int total_checks + int successful_checks + int failed_checks + float uptime_percentage + float check_pass_ratio + float avg_response_time_ms + float p95_response_time_ms + int incidents_count + } + + USERS ||--o{ WORKSPACE_MEMBERS : "belongs to (role)" + WORKSPACES ||--o{ WORKSPACE_MEMBERS : "has members" + + WORKSPACES ||--o{ MONITORS : "owns" + WORKSPACES ||--o{ INCIDENTS : "owns" + WORKSPACES ||--o{ ALERT_RULES : "configures" + WORKSPACES ||--o{ NOTIFICATIONS : "owns" + WORKSPACES ||--o{ AUDIT_LOGS : "logs" + WORKSPACES ||--o{ API_KEYS : "issues" + WORKSPACES ||--o{ METRIC_SNAPSHOTS : "aggregates" + + MONITORS ||--o{ CHECKS : "produces" + MONITORS ||--o{ INCIDENTS : "triggers" + MONITORS ||--o{ METRIC_SNAPSHOTS : "rolls up into" + + INCIDENTS ||--o{ NOTIFICATIONS : "fans out to" + ALERT_RULES ||--o{ NOTIFICATIONS : "delivers via" + + USERS ||--o{ REFRESH_TOKENS : "owns sessions" + USERS |o--o{ MONITORS : "created by (optional)" + USERS |o--o{ API_KEYS : "created by (optional)" + USERS |o--o{ AUDIT_LOGS : "performed by (nullable for system events)" +``` + +## Notes on design decisions + +- **UUID primary keys everywhere** — avoids leaking sequential IDs across + workspace boundaries and lets rows be created client-side / merged across + environments without collision. +- **`workspace_id` on almost every table** — Sentinel is multi-tenant; every + domain table (monitors, incidents, alert rules, notifications, audit logs, + API keys, metric snapshots) is scoped to a workspace and every repository + query filters on it. See [`docs/INTERVIEW_NOTES.md`](INTERVIEW_NOTES.md) + for the "why multi-tenant" rationale. +- **Soft delete on `monitors`** — `deleted_at` (nullable) instead of a hard + delete, so historical `checks`/`incidents`/`metric_snapshots` referencing + a deleted monitor remain intact for audit/analytics. A partial unique index + (`uq_monitors_workspace_id_monitor_type_target`, `WHERE deleted_at IS + NULL`) allows re-creating a monitor with the same type+target after the + original is deleted. +- **`ON DELETE SET NULL` for "created by" / "actor" FKs** (`monitors. + created_by_user_id`, `api_keys.created_by_user_id`, + `audit_logs.user_id`) — deleting a user doesn't cascade-delete the + resources they created or the audit trail of their actions; `user_id` + becomes `NULL`, and `audit_logs.user_id = NULL` is also used deliberately + for **system-generated** events (auto incident open/resolve, notification + delivery worker). +- **`refresh_tokens.id` doubles as the JWT `jti` claim** — revocation is a + single indexed `UPDATE refresh_tokens SET revoked = true WHERE id = :jti`, + no separate denylist table for refresh tokens (access tokens use a + short-TTL Redis denylist instead — see + [`docs/INTERVIEW_NOTES.md`](INTERVIEW_NOTES.md)). +- **`metric_snapshots` unique on `(monitor_id, period_type, period_start)`** + — the daily aggregation job is idempotent; re-running it for a day it has + already processed upserts the existing row instead of duplicating it. +- **Indexes** added in migration `0008` on high-cardinality query paths: + `checks(created_at)`, `checks(monitor_id, created_at)`, + `checks(monitor_id, status, created_at)`, `incidents(status)`, + `incidents(created_at)`, `notifications(status)`, + `monitors(is_active)`, `monitors(deleted_at)` — these back the dashboard, + metrics, and notification-dispatch queries. + +For full column-level detail, see the SQLAlchemy models in `app/models/` and +the migration history in `alembic/versions/`. diff --git a/docs/INTERVIEW_NOTES.md b/docs/INTERVIEW_NOTES.md new file mode 100644 index 0000000..813aff1 --- /dev/null +++ b/docs/INTERVIEW_NOTES.md @@ -0,0 +1,184 @@ +# Interview Notes: Design Decisions + +Short, defensible answers to "why did you build it this way?" — each +grounded in the actual implementation, not just theory. + +## Why PostgreSQL? + +Sentinel's data is fundamentally **relational and transactional**: +workspaces own monitors, monitors own checks/incidents, incidents fan out +to notifications, and almost every table carries a `workspace_id` foreign +key that must stay consistent. Postgres gives: + +- **ACID transactions** for multi-step writes — e.g. recording a check, + updating `monitors.consecutive_failures`/`status`, and opening an + `Incident` all happen in one transaction (`CheckService.record_check`), + so a crash can't leave a monitor `down` with no incident or vice versa. +- **Rich querying for analytics** — `MetricsService` uses + `percentile_cont` window functions directly in SQL to compute p50/p95/p99 + latency, rather than pulling rows into Python. +- **JSONB** for `audit_logs.old_values`/`new_values` — flexible, + per-entity diff payloads without a sprawling EAV schema, while still + being queryable. +- **Partial/composite indexes** — e.g. the soft-delete unique constraint + (`uq_monitors_workspace_id_monitor_type_target WHERE deleted_at IS NULL`) + enforces "no duplicate active monitor" at the database layer, not just + in application code. + +## Why Redis? + +Redis is used for everything that is **fast, ephemeral, or naturally +queue-shaped** — deliberately kept separate from Postgres so the source of +truth never depends on it: + +- **Celery broker + result backend** for background jobs (see "Why + Celery?"). +- **Application cache** (`app/services/cache.py`) for read-heavy endpoints + like the workspace dashboard. +- **Rate limiting** (`RateLimitMiddleware`) — fixed-window counters per + user/IP, cheap atomic `INCR`+`EXPIRE`. +- **JWT access-token denylist** — logout/`logout-all` add the token's + claims to Redis with a TTL matching the token's remaining lifetime, so + revocation is instant without hitting Postgres on every request. +- **Login-failure counters** for account lockout. + +Every Redis call is wrapped to catch `RedisError` and **fail open** — if +Redis is unreachable, caching/rate-limiting/denylist checks are skipped and +the request still succeeds against Postgres. This means Redis is a +performance/security *enhancement*, never a hard dependency for +correctness — a deliberate availability trade-off (a revoked access token +might work for a few extra seconds during a Redis outage, but the API +doesn't go down). + +## Why Celery? + +Health checks, alert delivery, and metric aggregation are all **slow, +external-I/O-bound, and need retries/scheduling** — exactly the wrong +things to do inline in a request handler: + +- **Scheduling** — Celery Beat replaces a cron job + script with + in-process, version-controlled schedules (`app/core/celery_app.py`, + `beat_schedule`): `dispatch_due_checks`, + `dispatch_pending_notifications`, and the daily + `aggregate_monitor_metrics` dispatch. +- **Decoupling** — a slow webhook/SMTP call or a TCP probe with a 10s + timeout never blocks an API request; it runs in a worker process and the + result is written back via the same service layer + (`CheckService.record_check`). +- **Retries built in** — failed notifications stay `pending`/`failed` with + an `attempts` counter and are picked up again by the next Beat tick (up + to `NOTIFICATION_MAX_ATTEMPTS`), without custom retry-loop code. +- **Testability** — `CELERY_TASK_ALWAYS_EAGER=true` runs the exact same + task functions synchronously, in-process, with no broker. Tests and CI + exercise the real `_perform_check`/`deliver_notification` code paths, + not mocks of "the worker." + +## Why Multi-Tenant Architecture? + +Sentinel is built as a **shared-schema, row-level multi-tenant** system: +one database, one set of tables, and a `workspace_id` foreign key on +almost every domain table (monitors, incidents, alert rules, +notifications, audit logs, API keys, metric snapshots — see +[`docs/ER_DIAGRAM.md`](ER_DIAGRAM.md)). + +- **Why shared schema over DB-per-tenant**: a monitoring SaaS has many + small tenants (workspaces) — provisioning a database per workspace would + multiply operational overhead (migrations, connections, backups) for no + benefit at this scale, and cross-workspace analytics/admin tooling would + need fan-out queries. +- **Isolation is enforced at the repository layer** — every repository + method that reads/writes domain data takes a `workspace_id` and filters + on it, and every endpoint resolves a `WorkspaceMembership` dependency + first (`app/api/deps.py`), so a user literally cannot construct a query + that crosses workspace boundaries. +- **It's what makes RBAC meaningful** — roles (`owner`/`admin`/`member`) + are defined *per workspace* via `WorkspaceMember`, not globally, so the + same user can be an `owner` of one workspace and a `member` of another. + +## Why RBAC? + +Three roles — `owner`, `admin`, `member` — cover the realistic permission +boundaries for an incident-management tool without over-engineering a +generic permissions system: + +- **`member`** — day-to-day usage: create/view monitors, view incidents, + view dashboards/metrics, record check results. +- **`admin`/`owner`** — anything that changes shared configuration or + exposes sensitive data: alert rules (where alerts go), API keys (who can + call the API programmatically), audit logs (who-did-what), member roles. +- **`owner`** — workspace deletion; the one truly destructive, + irreversible action. + +Enforcement is **declarative**, via typed FastAPI dependencies +(`WorkspaceMembership`, `AdminOrOwner`, `OwnerOnly` in `app/api/deps.py`) +that resolve "does this user belong to this workspace, and with what +role?" once per request and raise `PermissionDeniedError` (→ `403`) +otherwise. Handlers never write `if role == "admin"` checks themselves — +the dependency *is* the check, so it can't be forgotten on a new endpoint +and it's covered by a single set of tests. + +## Why Incident Thresholds? + +A single failed check is usually noise (a transient network blip, a +deploy-time restart), not an incident — paging someone for every failure +trains them to ignore alerts. `failure_threshold` (per-monitor, default 3) +is Sentinel's answer to **alert fatigue vs. detection speed**: + +- `consecutive_failures` increments on each `failure` check and **resets + to zero on any `success`** — only a *sustained* run of failures opens an + incident, so flapping services don't spam alerts. +- The threshold is **configurable per monitor** — a critical + payment-gateway health check might use `failure_threshold=1` (page + immediately), while a low-priority internal tool might use `5` (tolerate + more noise before paging). +- Resolution is symmetric and automatic: one `success` after an open + incident flips the monitor back to `up` and resolves the incident + (`reason="monitor_recovered"`) — no manual "close the ticket" step for + the common case, while admins can still `acknowledge`/manually `resolve` + for cases that need human follow-up. + +This turns "is this thing up?" from a binary per-check fact into a +**stateful incident lifecycle**, which is what the Notification Engine +actually fans out on (incident opened/resolved — not every check). + +## Why Caching? + +Sentinel caches **derived, read-heavy, slightly-stale-tolerant** data — +not transactional state: + +- The workspace dashboard aggregates counts across monitors/incidents/ + checks; recomputing it on every poll from a monitoring UI is wasted + Postgres load for data that changes on the order of seconds, not + milliseconds. +- The cache is **read-through with a short TTL** and every write path that + could affect cached data still writes Postgres first — Redis is never + the source of truth, so a cache miss or a Redis outage just means "go + compute it from Postgres," not "the data is wrong." +- Combined with the fail-open Redis pattern (see "Why Redis?"), caching is + purely an optimization: correctness doesn't regress if it's disabled or + unavailable, only latency/DB load does. + +## Why Audit Logs? + +An incident-management platform is itself part of an organization's +compliance and on-call story — "who changed this alert rule right before +we stopped getting paged?" needs to be answerable. + +- **Immutable by design** — the audit log API exposes only `GET` routes; + there is no update/delete path, so the trail can't be tampered with after + the fact. +- **Before/after diffs** (`old_values`/`new_values` JSONB) — not just "X + updated the monitor," but exactly which fields changed and to what, + which is what makes an audit log useful for debugging, not just + compliance. +- **Redaction** — fields matching + `password|secret|token|api_key|hashed_key|^key$` are stripped before + storage, so the audit trail itself can't become a credential leak. +- **System events included** — automatic incident open/resolve and + notification delivery write audit entries with `user_id=null`, + distinguishing "the system did this because a threshold was crossed" + from "an admin did this." Without this, the audit log would have gaps + around the most operationally important events. +- **Request context captured** (`ip_address`, `user_agent` via + `AuditContextDep`) — useful for security investigations (e.g. "was this + change made from an unexpected location?"). diff --git a/docs/RESUME_BULLETS.md b/docs/RESUME_BULLETS.md new file mode 100644 index 0000000..a16b7e3 --- /dev/null +++ b/docs/RESUME_BULLETS.md @@ -0,0 +1,87 @@ +# Resume Bullet Points + +Pick 3-5 depending on the role. Each is self-contained and quantifies the +engineering decision, not just the technology used. + +## Project summary (one-liner) + +> Sentinel — a multi-tenant observability and incident-management API +> (FastAPI, PostgreSQL, Redis, Celery) with automated health checks, +> threshold-based incident detection, alert delivery, analytics, and a +> full audit trail; CI/CD via GitHub Actions with a 95% coverage gate. + +## Distributed Monitoring + +- Built a Celery-beat-driven monitoring engine that schedules and executes + HTTP, TCP, and PING health checks per monitor on independent intervals, + decoupling probe execution from the request/response cycle. +- Implemented three protocol-specific health checkers (HTTP status-code + validation, raw TCP connect, OS-level ping) behind a single async + interface with bounded timeouts to prevent slow targets from blocking + worker capacity. + +## Incident Management + +- Designed a stateful incident lifecycle driven by a configurable + per-monitor failure threshold — consecutive failures automatically open + an incident, and the next successful check auto-resolves it, reducing + alert noise from transient failures. +- Added manual acknowledge/resolve workflows for admins on top of the + automatic lifecycle, with every transition recorded in an immutable + audit log including the reason (`automatic` vs. `manual`). + +## Multi-Tenant Architecture + +- Architected a shared-schema multi-tenant data model where every domain + table is scoped by `workspace_id`, with isolation enforced at the + repository layer so cross-tenant data access is structurally impossible. +- Implemented role-based access control (owner/admin/member) per + workspace via reusable FastAPI dependencies, enforcing authorization + declaratively across 12 routers and 40+ endpoints. + +## Background Processing + +- Built an async notification pipeline (Celery + Redis) that fans out + incident open/resolve events to per-workspace alert rules (webhook via + `httpx`, email via `aiosmtplib`), with retry tracking and delivery-status + audit logging. +- Implemented a daily metrics-aggregation job that upserts idempotent + `MetricSnapshot` rows per monitor, enabling historical uptime/latency + reporting without re-scanning raw check history. +- Used `CELERY_TASK_ALWAYS_EAGER` to run the identical task code + synchronously in tests/CI, achieving full coverage of worker logic + without a message broker dependency in the test environment. + +## Caching + +- Added a Redis-backed read-through cache for hot read endpoints (e.g. + workspace dashboard) with a fail-open degradation path, so a Redis + outage degrades latency, not correctness. +- Implemented Postgres window-function (`percentile_cont`) queries for + on-demand p50/p95/p99 latency and uptime/SLA reporting over arbitrary + date ranges. + +## Security + +- Implemented JWT auth with refresh-token rotation, single-session and + all-sessions revocation (logout / logout-all) backed by a Redis + access-token denylist, and per-workspace API keys for service-to-service + auth. +- Added defense-in-depth middleware: fixed-window rate limiting (stricter + on auth endpoints), account lockout after repeated failed logins, + request-size limits, and security headers (CSP, HSTS, X-Frame-Options). +- Built an audit-logging system capturing before/after diffs, IP address, + and user agent for every mutation, with automatic redaction of + credential-like fields before storage. + +## CI/CD + +- Designed a GitHub Actions pipeline with five independent jobs (lint, + type-check, test + coverage, Docker build validation, dependency/secret + scanning) gating merges to `main` via branch protection. +- Enforced a 95% line+branch coverage threshold across an async codebase + (SQLAlchemy async ORM + Celery), configuring coverage to track execution + across `greenlet` and `thread` contexts for accurate measurement. +- Diagnosed and fixed a CI-only test hang caused by a Python 3.12 + `asyncio` behavior change, adding `pytest-timeout` as a regression + safety net. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..24b8341 --- /dev/null +++ b/docs/screenshots/README.md @@ -0,0 +1,29 @@ +# Portfolio Screenshots + +This directory holds the screenshot assets referenced from the main +[`README.md`](../../README.md) "Portfolio Assets" section. They aren't +generated automatically (Sentinel must be running locally, and capturing a +screen isn't something this environment can do) — capture each one +following the steps below, save it with the exact filename listed, and the +links in the root README will resolve. + +| Filename | What to capture | How | +|---|---|---| +| `swagger-ui.png` | Swagger UI endpoint list | `docker compose up -d` (or `uvicorn app.main:app --reload`), open `http://localhost:8000/docs`, screenshot the full page showing the grouped endpoint tags (auth, workspaces, monitors, incidents, alerting, metrics, audit-logs, api-keys) | +| `api-response.png` | A live API call and response | In Swagger UI, expand `POST /api/v1/auth/register` or `GET /api/v1/workspaces/{id}/dashboard`, click **Try it out** → **Execute**, screenshot the request + response panel | +| `docker-compose.png` | Local deployment running | After `docker compose up -d`, run `docker compose ps` in a terminal — screenshot showing all five services (`db`, `redis`, `api`, `worker`, `beat`) as `healthy`/`running` | +| `ci-pipeline.png` | CI/CD pipeline | Open the [Actions tab](https://github.com/sarangs1621/Sentinel/actions) on a recent successful run, screenshot the job graph showing `Lint`, `Type check`, `Test & coverage`, `Docker build validation`, and `Security checks` | + +## Architecture & ER diagrams + +The architecture and ER diagrams are **not screenshots** — they're +Mermaid diagrams checked into source so they stay in sync with the code +and render directly on GitHub: + +- [`docs/ARCHITECTURE.md`](../ARCHITECTURE.md) +- [`docs/ER_DIAGRAM.md`](../ER_DIAGRAM.md) + +If you want PNG/SVG exports of these for a slide deck, render them with the +[Mermaid CLI](https://github.com/mermaid-js/mermaid-cli) (`mmdc -i +ARCHITECTURE.md -o architecture.png`) or paste the diagram source into the +[Mermaid Live Editor](https://mermaid.live) and export. From 277f13fc297d439217b4ed57cdd5b63f395a7b0e Mon Sep 17 00:00:00 2001 From: sarangs1621 Date: Sat, 13 Jun 2026 03:55:12 +0530 Subject: [PATCH 2/2] docs: address CodeRabbit review feedback on portfolio docs - Reference API_V1_PREFIX instead of a hardcoded OpenAPI path. - Add 'text' language tags to ASCII/directory-tree fenced blocks. - Clarify that CELERY_TASK_ALWAYS_EAGER doesn't exercise broker/worker behavior (serialization, scheduling, retries/acks). - Fix invalid 'audit_logs.user_id = NULL' SQL to 'IS NULL'. - Fix Mermaid CLI export paths in docs/screenshots/README.md. - Add a macOS/Linux venv activation line alongside the Windows one. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 ++- docs/API.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/DEPLOYMENT.md | 5 ++++- docs/ER_DIAGRAM.md | 2 +- docs/screenshots/README.md | 4 +++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 499b365..b8779e9 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ trail. ## Architecture Overview -``` +```text Client ──HTTPS──> FastAPI ──SQLAlchemy (async)──> PostgreSQL │ ├──cache / rate-limit / denylist──> Redis @@ -139,6 +139,7 @@ This starts `db` (Postgres), `redis`, `api`, `worker`, and `beat`. The ```bash python -m venv .venv . .venv/Scripts/activate # Windows +source .venv/bin/activate # macOS/Linux pip install -r requirements-dev.txt alembic upgrade head uvicorn app.main:app --reload diff --git a/docs/API.md b/docs/API.md index 0a52360..3c5c60c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,7 +1,7 @@ # API Reference Base path: `{API_V1_PREFIX}` (default `/api/v1`). Interactive docs at -`/docs` (Swagger UI) and `/api/v1/openapi.json` (OpenAPI schema). +`/docs` (Swagger UI) and `{API_V1_PREFIX}/openapi.json` (OpenAPI schema). **Auth column legend:** - `–` — no authentication required diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6bf3a9e..69f409f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -150,7 +150,7 @@ exceptions and stay framework-agnostic and unit-testable. ## 4. Code layout -``` +```text app/ ├── api/v1/endpoints/ # FastAPI routers — request/response only ├── api/deps.py # auth, RBAC, audit-context dependencies diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e4b9cdf..fb4f318 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -121,7 +121,10 @@ celery -A app.core.celery_app beat --loglevel=info For local development/CI without Redis, set `CELERY_TASK_ALWAYS_EAGER=true` — `.delay()`/`.apply_async()` calls execute the task function synchronously in the calling process, so the same code path is exercised by tests without -a broker. +a broker. Note that eager mode does **not** exercise broker/worker behavior +such as message serialization, broker-side scheduling, network delivery, or +broker-mediated retries/acks — validate those with a real broker/worker setup +or integration tests. ## Health Checks diff --git a/docs/ER_DIAGRAM.md b/docs/ER_DIAGRAM.md index ec8ec15..41e7267 100644 --- a/docs/ER_DIAGRAM.md +++ b/docs/ER_DIAGRAM.md @@ -181,7 +181,7 @@ erDiagram created_by_user_id`, `api_keys.created_by_user_id`, `audit_logs.user_id`) — deleting a user doesn't cascade-delete the resources they created or the audit trail of their actions; `user_id` - becomes `NULL`, and `audit_logs.user_id = NULL` is also used deliberately + becomes `NULL`, and `audit_logs.user_id IS NULL` is also used deliberately for **system-generated** events (auto incident open/resolve, notification delivery worker). - **`refresh_tokens.id` doubles as the JWT `jti` claim** — revocation is a diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 24b8341..0fc8a1c 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -25,5 +25,7 @@ and render directly on GitHub: If you want PNG/SVG exports of these for a slide deck, render them with the [Mermaid CLI](https://github.com/mermaid-js/mermaid-cli) (`mmdc -i -ARCHITECTURE.md -o architecture.png`) or paste the diagram source into the +../ARCHITECTURE.md -o architecture.png`, and likewise `mmdc -i +../ER_DIAGRAM.md -o er-diagram.png`, when run from `docs/screenshots/`) or +paste the diagram source into the [Mermaid Live Editor](https://mermaid.live) and export.