Skip to content

Auth and identity#

Ticket Board has one identity model and three carriers. The model is a CurrentActor — a frozen dataclass that knows the workspace, the user, and (for bots) the project allowlist and read/write mode. Every request that mutates state must produce one. Routers extract it; services act on it; the audit log records it.

The three carriers — session cookie, X-API-Key header, and MCP path token — funnel into the same actor object. Downstream code never branches on transport.

This page is the operator-facing summary. The exhaustive spec lives in spec/04_auth_and_identity.md. The bot URL decision is ADR-0002; the single-user-table decision is ADR-0003.

Tenets#

  1. One actor object, three doors. Session cookie, X-API-Key header, and MCP path token all resolve to the same CurrentActor dataclass on request.state. Services never branch on transport.
  2. Resolved exactly once per request. Authentication runs at the edge in middleware. Downstream code reads from request state; re-deriving the actor is a bug.
  3. Authorization lives in services, not routers. Routers extract the actor and call services. The service is the only place that says "this actor may do this thing."
  4. Audit every auth event. Successful logins, failed logins, token issuance, rotation, revocation, bot enable/disable — all flow into the same audit log as domain events.
  5. Bot credentials are bearer secrets. Tokens are never logged, never echoed after creation, and never compared with non-constant-time operations.

The actor model#

@dataclass(frozen=True, slots=True)
class CurrentActor:
    user_id: UUID
    workspace_id: UUID
    type: ActorType                          # HUMAN or BOT
    username: str
    display_name: str
    bot_mode: BotMode | None                 # None for humans
    bot_project_allowlist: tuple[UUID, ...] | None  # None for humans
    session_id: UUID | None                  # None for bots
    request_id: UUID                         # echoed back as X-Request-Id

Rules:

  • type=HUMANbot_mode is None, bot_project_allowlist is None, session_id is not None.
  • type=BOTbot_mode is not None, bot_project_allowlist is not None (may be empty), session_id is None.
  • Frozen: services pass it around without worrying about mutation.
  • Not an ORM object. The user row is the source of truth; CurrentActor is the per-request snapshot.

The dataclass lives in domain/actor.py and depends on nothing — no SQLAlchemy, no FastAPI. The frozenness and the absence of ORM coupling are deliberate: the actor is the cleanest object in the system and must stay that way.

Authentication resolution#

Resolution happens at two sites with an identical output contract: every authenticated request produces one CurrentActor exactly once before any route or tool handler runs.

  • REST middleware (auth/middleware.py::ResolveActorMiddleware). Resolves humans and bots for everything under /api/v1/*. It explicitly short-circuits to None for paths starting with /mcp/bot/ so the MCP shim below has sole ownership of MCP auth.
  • MCP ASGI shim (mcp/context.py::McpTokenAuthApp). Mounted at /mcp/bot; resolves the bot from the URL path segment before the request reaches the REST middleware. The resolved actor is placed on a contextvar (_current_actor_var) that tool handlers read via get_current_actor().

The split exists because FastMCP's StreamableHTTP transport is mounted as a sub-application; auth must happen before FastMCP dispatches, so it lives in the shim rather than the request middleware.

The REST middleware's resolution order, in precedence:

  1. X-API-Key header. If the header is present and non-empty, resolve as a bot. If a session cookie is also present, reject as ambiguous.
  2. tb_session cookie. If no header and a valid cookie is present, resolve as a human.
  3. None. The actor is unauthenticated. Most endpoints reject; a small allowlist (login, health, diagnostics) permits anonymous access.

The MCP shim's contract is simpler: it parses /mcp/bot/{token}/..., resolves the bot, and rejects the request before FastMCP sees it on any failure (unknown prefix, revoked, hash mismatch).

flowchart TD
    REQ[Request] --> P{Path matches<br/>/mcp/bot/?}
    P -- yes --> MCP[Resolve bot<br/>from URL]
    P -- no --> X{X-API-Key<br/>header?}
    X -- yes --> C1{Cookie<br/>also set?}
    C1 -- yes --> AMB[401 ambiguous_credentials]
    C1 -- no --> XBOT[Resolve bot<br/>from header]
    X -- no --> C2{tb_session<br/>cookie?}
    C2 -- yes --> HUM[Resolve human<br/>from session]
    C2 -- no --> ANON[Actor = None]

Conflict and failure semantics#

Condition Outcome HTTP Envelope code
MCP path, valid token bot actor n/a n/a
MCP path, invalid/revoked token reject n/a auth.invalid_credentials
REST path, X-API-Key valid bot actor
REST path, X-API-Key invalid/revoked reject 401 auth.invalid_credentials
REST path, cookie expired or unknown reject 401 auth.invalid_credentials
REST path, both X-API-Key AND cookie present (any state) reject 401 auth.ambiguous_credentials
REST path, no credential, endpoint requires auth reject 401 auth.unauthenticated
REST path, no credential, endpoint is anonymous proceed (actor=None)

The "both credentials present" case is rejected even if both are individually valid. A client cannot accidentally swap identities — an SDK bug that adds a cookie to a bot's request must surface, not silently take the cookie.

Human sessions#

Attribute Value
Name tb_session
Value 32 random bytes URL-safe base64 (~43 chars). Mapped server-side.
Flags HttpOnly; Secure (prod); SameSite=Lax; Path=/
Max-Age absent — session-lifetime cookie; server-side expiry is the truth
Domain omitted (host-only)

The cookie value is a bearer credential. We persist only its SHA-256 hash in the session table. The cookie has 256 bits of entropy, so no per-session salt is needed.

Why server-side sessions instead of JWT?#

Server-side sessions are the right fit here because the system is single-process, revocation is a real requirement, and the audit log already wants a session id for correlation. JWT's stateless-verify advantage does not earn its keep at this scale:

  • One DB lookup per request is trivial against a co-located Postgres.
  • Revocation requires either a denylist (defeating statelessness) or a short TTL (defeating UX).
  • Server-side sessions are revocable in one DB write, observable in admin views, and don't bloat headers.

Lifecycle#

Event Behavior
Idle timeout SESSION_IDLE_TIMEOUT_DAYS (default 7). If now() - last_seen_at > idle, reject with auth.invalid_credentials; emit auth.session.expired(reason='idle').
Absolute timeout SESSION_ABSOLUTE_TIMEOUT_DAYS (default 30). Past expires_at, no extension is possible; emit auth.session.expired(reason='absolute').
Logout Sets revoked_at = now(). Cookie is cleared via Set-Cookie: tb_session=; Max-Age=0.
Password change Self-service change (POST /auth/change-password) keeps the current session and revokes the user's other sessions, so the user stays logged in where they made the change while any compromised sessions elsewhere are cut. CLI recovery (reset_password.py) revokes all sessions.
Last-seen throttle Middleware writes last_seen_at only if older than 60 seconds. Idle math uses the in-memory now() minus the stored value; the throttle affects only audit-trail granularity.
Sweeper Hard-deletes rows where expires_at < now(). Idle-expired rows are caught here too once they cross absolute.

Login flow#

sequenceDiagram
    autonumber
    participant Browser
    participant API as FastAPI /auth/login
    participant Svc as auth_service
    participant DB as Postgres

    Browser->>API: POST /api/v1/auth/login { username, password }
    API->>Svc: authenticate_human(username, password, ip, ua)
    Svc->>DB: SELECT user WHERE username = ? AND type='human' AND is_active=true
    DB-->>Svc: user row
    Svc->>Svc: bcrypt.verify(password, user.password_hash)
    Svc->>DB: INSERT session (user_id, token_hash, ip, ua, expires_at)
    Svc->>DB: emit audit_event(auth.login.success)
    Svc-->>API: (user, session_token)
    API-->>Browser: 200 { user } + Set-Cookie: tb_session=<token>

    Note over Browser,API: Subsequent requests
    Browser->>API: GET /api/v1/projects (cookie)
    API->>API: middleware: sha256(cookie) → DB lookup → CurrentActor
    API-->>Browser: 200 { ... }

Login failure handling#

Cause HTTP Audit event
Unknown username 401 auth.login.failure reason=user_not_found
User inactive 401 auth.login.failure reason=inactive
Wrong password 401 auth.login.failure reason=wrong_password
Rate-limit hit 429 auth.login.rate_limited (bucket: ip or username)

The 401 response is identical across the first three causes. The audit log carries the precise reason. This avoids username enumeration while keeping the security log useful.

Password requirements#

  • Hashing: bcrypt with cost ≥ 12.
  • Minimum length: 12 characters. No maximum.
  • No composition rules (no required digits, no required symbols). Per NIST SP 800-63B, composition rules push users toward predictable patterns and don't increase entropy at the margin.
  • No breached-password check in v1. (haveibeenpwned k-anonymity API is the right tool; out of scope for the v1 launch.)
  • No rotation policy. Forced periodic rotation is anti-NIST and creates churn. We rotate on suspicion, not on schedule.

Self-service password change#

A signed-in human changes their own password from Settings → Security (/settings/security in the web app), which calls POST /auth/change-password. The endpoint is human-only and requires the X-Requested-By: web CSRF header.

  • Request: ChangePasswordRequest { current_password, new_password }. Success: 204 No Content.
  • Validation (all 400): auth.current_password_invalid when current_password is wrong; validation.password_too_short when new_password is under the 12-character minimum; validation.password_unchanged when new_password equals the current password. The same policy as recovery applies — minimum 12 characters, new password must differ from the old one.
  • Session handling: the current session is preserved and the user's other sessions are revoked (see the lifecycle table). Emits auth.password.changed with actor_kind='human'.

This is the routine path for a human rotating their own password. The CLI below is for recovery when the password is lost.

Admin recovery#

If the lone human loses their password, the recovery path is a CLI invocation run on the host:

make reset-password USER=<username>

The Makefile target wraps backend/scripts/reset_password.py, which prompts for a new password, applies the same validation rules as the API, writes a fresh bcrypt hash, and revokes all existing sessions. It emits an auth.password.reset_via_cli audit event with actor_kind='system'. The threat model is "John forgot his password," not "attacker pivots through the CLI" — the script requires shell or DB access to the running container.

Bot tokens#

Format#

tkb_<32 base32-lowercase>
  • tkb_ is a 4-character visible prefix. Two purposes: (1) operators can spot a leaked token in logs or repos via grep -E 'tkb_[a-z2-7]{32}', and (2) the prefix lets incident scanners filter without guessing.
  • 32 base32 chars carry 160 bits — generated as secrets.token_bytes(20) and base32-encoded. base32 is case-insensitive and copy-paste-friendly without ambiguous characters.
  • The first 8 chars after tkb_ are stored as bot_token.token_prefix (lookup hint, not auth). The remainder participates only in the argon2id hash.

Hashing#

argon2id with time_cost=3, memory_cost=64*1024, parallelism=4. argon2id was chosen over bcrypt because:

  • Bot tokens are verified far more often per second than human passwords — memory-hardness matters more.
  • bcrypt's 72-byte input limit and lack of memory-hardness are real drawbacks for machine-to-machine credentials.
  • argon2-cffi is mature and drops into a PasswordHasher-style interface cleanly.

Verification#

async def resolve_bot_by_token(token: str) -> CurrentActor:
    # Shape only — see auth/tokens.py for the real implementation.
    if not token.startswith("tkb_") or len(token) != 4 + 32:
        raise InvalidCredentials()
    prefix = token[4:12]                                  # 8 chars after "tkb_"
    candidates = await bot_token_repo.find_active_by_prefix(prefix)  # list, not row
    if not candidates:
        await argon2_hasher.verify(DUMMY_HASH, token)     # constant-time on miss
        raise InvalidCredentials()
    for row in candidates:
        if await argon2_hasher.verify(row.token_hash, token):
            if row.revoked_at is not None or not row.bot_user.is_active:
                raise InvalidCredentials()
            return build_actor(row.bot_user)
    raise InvalidCredentials()

The dummy verify on miss closes the obvious timing side channel where "wrong prefix" would return instantly and "wrong tail" would run argon2. The dummy hasher is cached per (time_cost, memory_cost, parallelism) tuple so the timing profile matches a real verify. The prefix lookup is a single indexed query; an 8-char base32 prefix collision is rare but possible, so the implementation iterates candidates rather than asserting a single match. A colliding prefix doesn't bypass the hash check.

Issuance and rotation#

Action What changes Plaintext returned?
POST /bots (create) New user row + first bot_token row (is_active=true). Yes — once.
POST /bots/{id}/tokens/rotate Active row stamped rotated_at = now(), is_active=false. New active row inserted. Yes — once.
POST /bots/{id}/tokens/revoke Active row stamped revoked_at = now(), is_active=false. No new row. No. Bot cannot authenticate.
DELETE /bots/{id} user.is_active = false. All bot tokens become unusable. No.

No grace period in v1. Rotation immediately invalidates the previous token. The trade-off:

  • Pro: simpler invariant ("exactly one active token per bot"), enforced by partial unique index. No "which of two valid tokens did this request use?" ambiguity in the audit log.
  • Con: a bot running with the old token gets auth errors until reconfigured. For a homelab with a handful of bots, the operator reconfigures them manually after rotation.

The shape of a future grace feature is documented but not implemented: add a superseded_at column distinct from rotated_at, and allow the previous row to authenticate until superseded_at + grace_window.

Plaintext handling#

  • Plaintext is held in a local variable in the create/rotate handler long enough to build the response, then garbage-collected.
  • Never written to any log, any audit payload, or any error trace.
  • The bot.token.created and bot.token.rotated audit events record token_id and token_prefix only.
  • The plaintext appears in the response body and as Authorization: Bearer ...-style guidance text in the response's usage field, so the operator copy-pastes a complete instruction rather than a bare token. The HTTP response is the only carrier.

MCP path token#

The token in /mcp/bot/{token} is the same value as the bot's X-API-Key. The URL itself is a credential. This is a deliberate choice driven by client-compatibility realities; see ADR-0002.

Because URLs are credentials in this design, logging redaction is mandatory at multiple layers:

Layer Redaction implementation
Caddy access log filter encoder with a regexp field sub-filter rewriting /mcp/bot/<token>/.../mcp/bot/<redacted>/....
Backend structlog middleware A processor in observability/logging.py applies the same regex to any log field that could contain a URL.
Tracing exporters Future: same regex before export. Not yet wired (tracing is v2).

Reverse proxies in front of the system must apply the same redaction in their access logs. This is an operator-runbook responsibility called out explicitly to ensure it isn't missed.

Authorization#

Authorization decisions live in the service layer, not in routers. Routers extract the actor via dependency injection (current_actor, current_human, current_bot, current_bot_writer) and call services. The service is the only place that says "this actor may do this thing."

Humans#

  • v1 has exactly one human user with full read/write authority within their workspace.
  • Endpoints flagged "humans-only" use the current_human dependency to reject bots.
  • No role/permission table in v1. When multi-human arrives, the model gains role assignment; CurrentActor already has room.

Bots#

Two-dimensional scope: project allowlist and read/write mode.

Project allowlist#

An empty allowlist ([]) grants the bot access to all projects in the workspace — it is the global-access sentinel value, not "no access". A non-empty allowlist restricts the bot to exactly those project ids.

Path Behavior
List/read Empty allowlist: unfiltered (all projects visible). Non-empty allowlist: service filters project_id IN actor.bot_project_allowlist. The repository base exposes apply_actor_project_scope(query, actor) for systematic, grep-able enforcement.
Single fetch Denied only when the allowlist is non-empty and the resource's project_id is not listed. Else: allowed. Denied case returns NotFound (not Forbidden) — same shape as classical IDOR defense.
Write Denied with Forbidden(reason='project_not_in_allowlist') only when the allowlist is non-empty and the target project is not in it. Empty-allowlist bots may write to any project.
Cross-project list Empty-allowlist bot: sees all projects. Non-empty-allowlist bot: sees only listed projects. No "you have N projects you can't see" hint.

Read/write mode#

  • Read endpoints/tools: callable by any bot that has project access (empty allowlist or project listed).
  • Write endpoints/tools: additionally require bot_mode == 'write'. Mismatch → Forbidden(reason='bot_read_only').
  • A write attempt by a read-only bot also emits auth.bot.write_attempt_in_read_mode so the operator can spot misconfigured bots.

Global wiki#

Global wiki pages (wiki_page.project_id IS NULL) are visible to all bots for reads. Writes are gated by the per-bot can_edit_global_wiki flag, defaulting to BOT_CAN_EDIT_GLOBAL_WIKI_DEFAULT (default false). A bot without the flag attempting a global write receives Forbidden(reason='bot_cannot_edit_global_wiki'). Humans bypass this check entirely.

Audit log access#

The audit log endpoint is humans-only. Bots cannot view audit events under any circumstances — the audit log is the security log, and bots are the subjects of investigation.

Authorization decision flow#

flowchart TD
    A[Incoming request] --> B{Authenticated?}
    B -- no --> R401[401 auth.unauthenticated]
    B -- yes --> C{Actor type?}
    C -- human --> D{Endpoint requires bot?}
    D -- no --> ALLOW1[Proceed]
    D -- yes --> R403H[403 auth.forbidden]
    C -- bot --> E{Endpoint requires human?}
    E -- yes --> R403B[403 auth.forbidden humans_only]
    E -- no --> F{Allowlist non-empty AND project not listed?}
    F -- yes --> R403P[403 auth.forbidden project_not_in_allowlist]
    F -- no --> G{Write op AND mode=read?}
    G -- yes --> R403R[403 auth.read_only_bot]
    G -- no --> ALLOW2[Proceed]

CSRF and security headers#

The session-cookie auth needs protection against cross-site requests:

  • Cookie attribute: SameSite=Lax blocks cross-site form posts to state-changing endpoints by default.
  • Custom header: state-changing JSON endpoints (POST/PATCH/DELETE) require X-Requested-By: web from browser clients. Cross-origin form posts cannot set the header without a preflight, and no CORSMiddleware is mounted in the FastAPI process — preflights for other origins simply fall off the routing table.
  • Bot SDKs are exempt — the X-API-Key header itself proves intent and origin.
  • Single-origin deployment. Caddy fronts both UI and API, so cross-origin requests cannot reach the backend through the proxy. If a different reverse proxy is placed in front of the API, origin-checking must be enforced there.

Edge headers (HSTS, X-Frame-Options, X-Content-Type-Options) are enforced at the reverse proxy. The deployment docs cover the Caddy configuration.

A startup check in lifespan.py asserts Settings.cookie_secure=True when Settings.env == "prod" and refuses to boot otherwise. This makes "ran prod with cookie_secure=false" an impossible mistake.

Rate limiting#

v1 enforces an in-memory sliding-window limiter on the login endpoint only. Per-actor throttling for general REST and MCP traffic is planned, not implemented — the column exists so a future limiter can read it without a migration.

Bucket Capacity Window Status
/auth/login per IP 5 attempts 15 min sliding enforced
/auth/login per username 5 attempts 15 min sliding enforced
Per-actor REST + MCP user.rate_limit_per_minute (default 60) per minute planned; column reserved
Bot token rotation per human planned

Settings.default_rate_limit_per_minute populates the column on user creation. Until the per-actor limiter ships, no middleware reads the value and no X-RateLimit-* headers are emitted.

The single-process design is acknowledged. Horizontal scale would require a shared store (Redis); the migration is sketched but out of v1 scope.

Settings#

The auth surface introduces (or formalizes) these env-driven settings. Defaults match what's in .env.example.

Setting Default Purpose
SESSION_IDLE_TIMEOUT_DAYS 7 Idle window for human sessions.
SESSION_ABSOLUTE_TIMEOUT_DAYS 30 Absolute lifetime; past this, no extension.
IDEMPOTENCY_KEY_TTL_HOURS 24 TTL applied to new idempotency records.
TOOL_INVOCATION_LOG_RETENTION_DAYS 90 How long tool_invocation_log rows live before the sweeper purges them.
DEFAULT_RATE_LIMIT_PER_MINUTE 60 Default user.rate_limit_per_minute for newly created actors.
BOT_CAN_EDIT_GLOBAL_WIKI_DEFAULT false Default can_edit_global_wiki for newly created bots.
SWEEPER_INTERVAL_MINUTES 60 Shared schedule for the session / idempotency / tool-log sweeper.
auth.bcrypt_cost 12 Human password hashing cost.
Settings.cookie_secure True in prod Asserted at startup when Settings.env == "prod"; refuses to boot otherwise.
Settings.docs_enabled False in prod Controls Swagger/Redoc exposure.

For the broader settings audit (and which env variables are redacted in startup logs), see the Deployment section.

Audit emission#

Auth events emit into audit_event like every other event. The full taxonomy is in Observability; the auth-specific entries:

event_type Trigger Payload (excerpt)
auth.login.success Human login OK {ip, user_agent}
auth.login.failure Any login failure {ip, user_agent, reason, username_attempted}
auth.login.rate_limited Login bucket exhausted {ip, username_attempted, bucket}
auth.logout Explicit logout {session_id}
auth.session.expired Auto-expiry on resolve {session_id, reason: 'idle'\|'absolute'}
auth.bot.success Bot auth OK {token_id, token_prefix, surface: 'rest'\|'mcp'}
auth.bot.failure Bot auth failed {token_prefix, surface, reason}
auth.bot.write_attempt_in_read_mode Read bot tried a write tool/endpoint {surface, target_kind, target_id}
auth.password.changed Self-service password change {username}actor_kind='human'
auth.password.reset_via_cli CLI password reset {username}actor_kind='system'
bot.created / bot.updated / bot.enabled / bot.disabled Bot lifecycle events {bot_user_id, ...}
bot.token.created / bot.token.rotated / bot.token.revoked Token lifecycle {bot_user_id, token_id, token_prefix}

Every row carries request_id for cross-actor correlation. A human's PATCH that rotates a bot token produces a bot.token.rotated whose request_id ties to the human's audit row for the same request.

Auth failure events bypass the unit of work#

Auth-failure events write immediately on a dedicated short-lived connection (audit_sink.write_now), not via the after-commit hook used for domain events. The failure itself is the security signal; it must persist even if the surrounding request transaction rolls back.

See Observability for the full pattern and the audit.orphaned defense net.

Bot authentication flow (REST)#

sequenceDiagram
    autonumber
    participant Bot
    participant API as FastAPI middleware
    participant Auth as auth/tokens.py
    participant DB
    participant Svc as service

    Bot->>API: GET /api/v1/tickets, X-API-Key: tkb_abc...xyz
    API->>Auth: resolve_bot_by_token("tkb_abc...xyz")
    Auth->>DB: SELECT bot_token WHERE token_prefix='abc' AND is_active=true
    DB-->>Auth: row(token_hash, bot_user_id)
    Auth->>Auth: argon2.verify(row.token_hash, "tkb_abc...xyz")
    Auth-->>API: CurrentActor (bot)
    API->>API: attach to request.state
    API->>Svc: ticket_service.list_tickets(actor, filters)
    Svc->>Svc: filter by actor.bot_project_allowlist
    Svc->>DB: SELECT tickets WHERE project_id IN (...) AND ...
    DB-->>Svc: rows
    Svc-->>API: TicketSummary[]
    API-->>Bot: 200 { data, page }

Token rotation by a human#

sequenceDiagram
    autonumber
    participant Human
    participant API as FastAPI
    participant Svc as bot_service
    participant Tokens as auth/tokens.py
    participant DB

    Human->>API: POST /api/v1/bots/{id}/tokens/rotate (cookie)
    API->>Svc: rotate_token(actor=human, bot_id)
    Svc->>Tokens: generate_plaintext_token() → "tkb_..."
    Svc->>Tokens: argon2_hash("tkb_...") → hash
    Svc->>DB: BEGIN
    Svc->>DB: UPDATE bot_token SET is_active=false, rotated_at=now() WHERE bot_user_id=? AND is_active=true
    Svc->>DB: INSERT bot_token (bot_user_id, token_hash, token_prefix, is_active=true)
    Svc->>DB: COMMIT
    Svc->>DB: (after-commit) INSERT audit_event(bot.token.rotated)
    Svc-->>API: { plaintext_token, new_token_row }
    API-->>Human: 200 { plaintext_token: "tkb_...", token_id, prefix }
    Note over Human,API: Plaintext is shown ONCE.

What this page does not cover#

  • Frontend cookie handling (how Next.js attaches cookies on fetch) — spec/06_frontend.md.
  • The REST endpoint catalog and which dependencies they use — see the REST API reference.
  • MCP tool authorization specifics — see the MCP server reference.
  • TLS termination, HSTS specifics, reverse-proxy configuration — see Deployment.