Skip to content

Security: nikoma/carrier

Security

docs/security.md

Carrier Security Notes And Threat Model

This is the minimum security review Carrier expects before calling the current surface release-ready. It documents what the runtime does today, what is tested, and where users still need discipline.

Trust Boundaries

Primary trust boundaries:

  • inbound HTTP requests
  • JWT bearer tokens
  • generated SQL hitting Postgres
  • pooled Postgres connections carrying per-request policy context
  • idempotent write routes
  • outbound HTTP client calls

Primary assets:

  • model data stored in Postgres
  • Carrier system tables (carrier_idempotency_keys, carrier_jobs, carrier_events, carrier_event_deliveries, carrier_audit_log, carrier_schedule_runs)
  • authenticated user context (CurrentUser)
  • audit/event history

JWT

Carrier's JWT contract today:

  • algorithm allowlist is pinned to HS256 by default, or RS256 when JWT_ALGORITHM=RS256
  • issuer and audience are validated
  • access tokens are distinguished from refresh tokens via token_type
  • clock skew leeway is configurable through JWT_CLOCK_SKEW_SECONDS
    • default: 30
  • secret rotation is supported by:
    • JWT_SECRET for new token issuance
    • JWT_SECRET_PREVIOUS as a comma-separated validation fallback list
  • RS256 verification uses JWT_JWKS_URL when set, otherwise Carrier derives the Keycloak-style JWKS URL from the issuer as {issuer}/protocol/openid-connect/certs
  • JWKS responses are cached for JWT_JWKS_CACHE_TTL_SECONDS
    • default: 300

Security implications:

  • unsigned or wrong-algorithm tokens are rejected because verification is locked to the configured algorithm
  • rotated tokens remain valid during the overlap window as long as the old secret stays in JWT_SECRET_PREVIOUS
  • removing an old secret immediately invalidates tokens signed with it
  • built-in Carrier token issuance is HMAC-only; when using RS256, issue tokens through the external identity provider such as Keycloak

Built-In Auth Helpers

Carrier's expanded auth helpers inherit the same local-first posture as the starter JWT flow:

  • password hashing uses Argon2
  • built-in password hashing rejects weak passwords by default
  • TOTP secrets are generated from secure random bytes and encoded for authenticator-app compatible otpauth URIs
  • magic links are signed and verified as one-time opaque tokens
  • OAuth authorization helpers issue PKCE S256 challenges and one-time callback state

Security implications:

  • built-in replay protection for magic links and OAuth callback state is currently in-process
  • multi-instance production deployments should replace or wrap that replay storage with shared durable state
  • keep password, magic-link, OAuth client, and provider secrets in environment/config values, not source files

SQL, sql.*, And db.*

Carrier-generated CRUD queries and DB helper invocations use bound parameters. User input is passed as values, not interpolated into SQL text.

What is safe by construction:

  • generated CRUD filters
  • generated list/search pagination parameters
  • db.fn_* helper argument passing
  • typed external client query/body serialization

What still requires discipline:

  • sql.one_as, sql.list_as, and related raw SQL helpers take authored SQL text directly
  • do not concatenate user-controlled strings into raw SQL text
  • keep raw SQL statements static and bind user data through helper arguments or generated CRUD

RLS And Pooled Connections

Carrier sets these Postgres session values before DB work:

  • carrier.current_roles
  • carrier.current_tenant
  • carrier.current_user

The runtime now has a test that exercises connection reuse and verifies that calling apply_policy_context(..., None) clears prior request state. Public or unauthenticated DB paths must still call the policy-context helper before query execution.

Idempotency

The runtime now serializes concurrent first-writers for the same key by:

  • deriving a deterministic advisory-lock key from (route_scope, idempotency_key)
  • taking a Postgres advisory lock before checking/storing the key
  • releasing followers only after the winning request stores the response

Scope construction is caller-aware:

  • public routes: {METHOD} {PATH}:public
  • authenticated routes: {METHOD} {PATH}:user:{id}:tenant:{tenant}:workspace:{workspace}

That keeps idempotency replays scoped to the caller context instead of leaking across tenants.

Audit Coverage

Carrier currently records durable audit rows for:

  • generated CRUD create
  • generated CRUD update
  • generated CRUD delete
  • generated CRUD restore
  • explicit audit.record(...) calls

Each generated audit row is linked into a SHA-256 hash chain with prev_hash and row_hash. Operators can run carrier compliance verify-audit-chain to prove the hot audit table is intact or identify the first detected break. For retention compaction, archive a contiguous verified prefix with its final chain head before removing it from the hot table.

Operational note:

  • policy-denied attempts should still be captured in structured request logs
  • durable audit rows for every possible deny path are not yet automatic across all route shapes

Operational Guidance

  • rotate JWT secrets with overlap instead of cutover-only swaps
  • treat raw SQL helpers as privileged escape hatches
  • keep Idempotency-Key generation client-side and stable across retries
  • review generated migrations before production rollout
  • ship structured logs and audit/event tables into your platform observability stack

There aren’t any published security advisories