The threat model, the defenses, and the boundaries — stated plainly. Every defense here is enforced by a test, not just described.
TaskFlow treats every request as hostile until proven otherwise. Authentication is constant-time and leaks nothing. Authorization is deny-by-default and tenant-isolated — you cannot even detect the existence of a resource in a workspace you don't belong to. Secrets never enter logs or config. Where a defense has a deliberate limit, that limit is filed as a tracked issue and named below, not hidden.
| Risk | Addressed by |
|---|---|
| A01 Broken Access Control | Every workspace-scoped route gated by require_workspace_role; row-level checks (author-or-admin) in the service; cross-tenant access is indistinguishable from "not found" |
| A02 Cryptographic Failures | argon2id password hashing; HS256 JWT signed with a ≥32-char secret (Pydantic enforces length); secrets are SecretStr; no secrets in committed config |
| A03 Injection | Parameterized queries via SQLAlchemy ORM only — no string-concatenated SQL anywhere; Pydantic validates every request body at the door |
| A05 Security Misconfiguration | Non-root container user (uid 1001); .env gitignored, .env.example documents the schema; DEBUG off by default; fail-fast config |
| A07 Identification & Authentication Failures | Constant-time login; unified 401 for any auth failure; no account enumeration on register or login; single InvalidToken for all JWT failures; HS256 hardcoded in the decoder |
| A09 Security Logging & Monitoring Failures | Append-only audit log of security-relevant events; hashed_password never appears in any response (asserted by test); emails never echoed in error bodies |
Deferred risks (no current attack surface) are tracked, not ignored — see Known boundaries below.
The system never reveals what an attacker hasn't earned the right to know.
-
Account enumeration — closed.
- Registering an email already in use → generic
400(email_unavailable), same as any other rejection - Login failure → identical
401(invalid_credentials) whether the password was wrong, the account doesn't exist, or anything else authenticate()runs a full argon2 verify even when the user doesn't exist — so timing can't distinguish the two paths. Measured timing ratio between "wrong password" and "no such user": ~1.04 (both ~80ms). Enforced by test.
- Registering an email already in use → generic
-
Resource existence — not disclosed across tenants.
- A request for a task / comment / workspace you have no access to
returns a
404that is byte-identical to one for a resource that genuinely doesn't exist - An outsider, a non-member, and a request for a random UUID all get the same response — you cannot probe "does workspace X exist" or "is there a task here" from outside
- A request for a task / comment / workspace you have no access to
returns a
-
Token failures — single bucket.
- Missing, malformed, expired, or bad-signature tokens all collapse
into one
InvalidToken→ identical401(invalid_token). The client learns "not authenticated," never why.
- Missing, malformed, expired, or bad-signature tokens all collapse
into one
Every API error has the same shape — a machine-readable code plus a
human message:
{ "detail": { "detail": "human message", "code": "machine_slug" } }The code set in use:
| Code | Meaning | Typical HTTP |
|---|---|---|
email_unavailable |
Registration email already taken | 400 |
invalid_credentials |
Login failed (cause not disclosed) | 401 |
invalid_token |
Auth token missing/malformed/expired/bad | 401 |
user_not_found |
Target user does not exist | 404 |
member_not_found |
Target is not a member of the workspace | 404 |
already_member |
Invitee is already a workspace member | 400/409 |
cannot_remove_owner |
Attempt to remove the workspace owner | 400 |
task_not_found |
Task absent, or not in this workspace | 404 |
comment_not_found |
Comment absent, or not under this task | 404 |
not_comment_author |
Edit/delete by someone without the right | 403 |
invalid_cursor |
Malformed pagination cursor | 400 |
Clients branch on code, never on prose. The doubled detail nesting
is FastAPI's envelope wrapping ours — accepted deliberately (see
DECISIONS.md); a custom exception handler can flatten
it later if a consumer needs it. The contract is itself
regression-locked: test_openapi_contract fails if the documented
error responses or the bearer scheme ever silently drift from reality.
Access tokens are short-lived; long-lived sessions use opaque, rotating refresh tokens with theft detection.
- Access token — HS256 JWT,
sub= user id,roleclaim, 15-minute TTL (configurable). Stateless; never stored server-side. - Refresh token — opaque random string, stored in Redis (its own logical DB), 30-day TTL.
- Single-use rotation — every refresh exchanges the old token for a new one; the old one is retained marked spent (retention is the theft signal).
- Family kill on replay — replaying an already-spent token means it was stolen (legitimate clients never reuse). The system invalidates the entire token family, forcing re-authentication. The event is recorded in the audit log, attributable to the user.
- Role re-read on refresh — a refresh re-reads the user's current role, so a role change takes effect within one access-token TTL, not 30 days.
- Logout — revokes the refresh token; idempotent (re-logout or an unknown token still returns success; a real session death is audited).
The full state machine — the happy path and both failure branches:
sequenceDiagram
autonumber
participant C as Client
participant API as TaskFlow API
participant RS as refresh_store<br/>(Redis)
participant AL as audit_log<br/>(Postgres)
Note over C,API: Login
C->>API: POST /v1/auth/login (email, password)
API->>RS: create token family
API-->>C: access (15m JWT) + refresh (opaque)
Note over C,API: Normal use
C->>API: request + Bearer access
API-->>C: 200 (until access expires)
Note over C,API: Refresh — happy path
C->>API: POST /v1/auth/refresh (refresh)
API->>RS: rotate(refresh)
RS-->>API: ok — old marked spent, new issued
API->>AL: auth.refreshed
API-->>C: new access + new refresh<br/>(role re-read here)
Note over C,API: Refresh — unknown / expired
C->>API: POST /v1/auth/refresh (bad token)
API->>RS: rotate(bad)
RS-->>API: NotFound
API-->>C: 401 invalid_token<br/>(identical to every other token failure)
Note over C,API: Refresh — replay of a spent token (theft)
C->>API: POST /v1/auth/refresh (already-spent)
API->>RS: rotate(spent)
RS-->>API: ReuseDetected (family_id, user_id)
API->>RS: kill entire family
API->>AL: auth.refresh_reuse_detected
API-->>C: 401 invalid_token<br/>(same bytes — no oracle)
Note over C,API: Logout (idempotent)
C->>API: POST /v1/auth/logout (refresh)
API->>RS: revoke
API->>AL: auth.logged_out (only if a live session died)
API-->>C: 204
The two failure branches return a byte-identical 401 — an
attacker replaying a stolen token learns nothing different from one
sending garbage. The difference is invisible to them and fully
recorded for the operator.
Known boundaries (tracked, not hidden)
Deliberate limits. Each is a filed issue, named here so a reviewer doesn't have to discover it.
-
Auth/security audit events have no operator read path — (tracked: #50). Authentication events (token refresh, detected theft, logout) are durably recorded, but the only audit read endpoint is workspace-scoped, and auth events are workspace-agnostic by design. The data is captured correctly; a global, admin-only read path is its own design effort, deliberately not bundled into unrelated work.
-
Task creation is not audited — (tracked: #55). Task deletion is recorded; task creation is not. An asymmetry an auditor would flag. Surfaced while building the activity feed; the fix (emit
task.created) is a small, separate audit-layer change, filed rather than scope-crept into a read-only feature.
Why this section exists: a system's documented limits are part of its security posture. Knowing what isn't covered, and tracking it, is the difference between a blind spot and a managed risk.
- A04 Insecure Design — a formal threat model document; deferred
- A06 Vulnerable Components — dependency scanning lands with CI (Phase C)
- A08 Software/Data Integrity — artifact signing lands with the Kubernetes phase (Phase C)
- A10 SSRF — not applicable; the service makes no outbound requests from user input
These are listed so their absence is a decision, not an oversight.
- ARCHITECTURE.md — where authz lives (route vs. service) and why
- API.md — per-endpoint auth requirements
- DECISIONS.md — the reasoning behind the error envelope, the seams, and the tradeoffs