Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
490 changes: 147 additions & 343 deletions README.md

Large diffs are not rendered by default.

148 changes: 148 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# API Reference

Base path: `{API_V1_PREFIX}` (default `/api/v1`). Interactive docs at
`/docs` (Swagger UI) and `{API_V1_PREFIX}/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`) |
170 changes: 170 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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

```text
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.
Loading
Loading