M03-api-auth-foundations.md 13 KB

M03 — API Auth Foundations

Fresh Claude Code agent prompt. M02 must be complete and committed. Estimated effort: large. This milestone is auth-critical; spend time on tests.

Mission

Implement the api's authentication and authorization foundations: token kinds (reporter, consumer, admin, service), token resolution, RBAC middleware, the impersonation header pattern for the UI BFF, and the auth endpoints (/api/v1/auth/users/upsert-oidc, /api/v1/auth/users/upsert-local, /api/v1/admin/me). Bootstrap the service token on startup. No business endpoints yet — only the auth machinery.

Before you start

  1. Verify M02 acceptance:

    git log --oneline -2     # M01, M02 commits
    cd api && composer test && composer stan && cd ..
    
  2. Read SPEC.md §6 (API Contracts — Authentication tokens, Auth API), §7 only the "Identity resolution flow" and RBAC matrix, §8 (Authentication & Authorization, the api-side parts), §14 (Coding Conventions).

  3. Pay particular attention in SPEC §8 to where each auth concern lives: the api owns token validation, the users table, RBAC. The ui owns OIDC redirects, sessions, password validation. This milestone is api-only.

Tasks

1. Token domain

In api/src/Domain/Auth/:

  • TokenKind.php — enum: Reporter, Consumer, Admin, Service.
  • Role.php — enum: Viewer, Operator, Admin. Method satisfies(Role $required): bool.
  • Token.php — value object representing a parsed token (kind, prefix, id after lookup, subject (reporter/consumer/admin user id), role for admin tokens).
  • TokenIssuer.php — generates tokens. Format: irdb_<kind3>_<32 base32 chars> where kind3 is rep|con|adm|svc. Uses random_bytes(20) (160 bits) → base32. Returns the raw token string.
  • TokenHasher.phphash(string $raw): string using SHA-256 hex. Pure function, easy to test.

In api/src/Infrastructure/Auth/:

  • TokenRepository.phpfindByHash(string $hash): ?TokenRecord, create(TokenRecord), markUsed(int $id, DateTimeImmutable). TokenRecord carries: id, kind, hash, prefix, reporter_id, consumer_id, role (for admin kind), expires_at, revoked_at, last_used_at. Apply WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now) in lookups.

2. User identity domain

In api/src/Domain/User/:

  • User.php — value object: id, subject (OIDC sub, nullable), email, displayName, role: Role, isLocal: bool.

In api/src/Infrastructure/Auth/:

  • UserRepository.php — methods:
    • findById(int): ?User
    • findBySubject(string): ?User (OIDC sub)
    • findLocalByUsername(string): ?User
    • upsertOidc(string $sub, string $email, string $displayName, array $groupIds, Role $defaultRole): User — looks up by sub; if not found, derives role from oidc_role_mappings (highest role granted by any matching group; default if none); inserts. If found, updates email/display_name and recomputes role from current group memberships.
    • upsertLocal(string $username): User — finds-or-inserts the local admin record with is_local=true, role=Admin. Always returns role Admin.
  • RoleMappingRepository.phpresolveRole(array $groupIds, Role $default): Role.

3. Middlewares

In api/src/Infrastructure/Http/Middleware/:

  • TokenAuthenticationMiddleware.php — extracts Authorization: Bearer ..., parses the kind prefix, looks up by hash, attaches a resolved AuthenticatedPrincipal to the request attributes. On failure: 401. Updates last_used_at async (write-behind is fine; for now, synchronous update is acceptable).
  • ImpersonationMiddleware.php — runs after TokenAuthenticationMiddleware. If the principal's token kind is Service:
    • require X-Acting-User-Id header; missing → 400 Bad Request with {"error":"missing X-Acting-User-Id"}.
    • look up the user; not found → 403.
    • replace the principal with one carrying that user's id and role.
    • For non-service tokens, X-Acting-User-Id is ignored (do not 400).
  • RbacMiddleware.php — route-attached middleware factory: RbacMiddleware::require(Role::Operator) returns a middleware that 403s if the principal doesn't have the role.
  • Extra: JsonErrorHandler.php — converts thrown exceptions to JSON error responses ({"error":"...","details":...}). Wire it as Slim's error handler.

AuthenticatedPrincipal (in api/src/Domain/Auth/):

final class AuthenticatedPrincipal {
    public function __construct(
        public readonly TokenKind $tokenKind,
        public readonly ?int $userId,           // present when service-impersonating or admin-token bound to user
        public readonly ?Role $role,            // null for reporter/consumer
        public readonly ?int $reporterId,
        public readonly ?int $consumerId,
        public readonly int $tokenId,
    ) {}
}

4. Service token bootstrap

In api/src/Infrastructure/Auth/ServiceTokenBootstrap.php:

  • Run on api container startup (call from entrypoint.sh via a bin/console auth:bootstrap-service-token command, or run inline at app boot before HTTP serving).
  • Reads UI_SERVICE_TOKEN env var. If empty: log a warning, skip.
  • If set: hash it, look up in api_tokens. If absent, insert a row with kind=service, hash, prefix (first 8 chars of raw), no FKs, no expiry. If present and hash matches an existing service-kind row, do nothing. If a different service-kind row exists, log a warning (rotation case) but do not auto-revoke — operator must clean up.

Update api/docker/entrypoint.sh so the api mode runs bin/console auth:bootstrap-service-token before launching FrankenPHP.

5. Routes

In api/src/Application/:

  • Auth/AuthController.php:
    • POST /api/v1/auth/users/upsert-oidc — service token only. Body {subject, email, display_name, groups: []}. Returns {user_id, role, email, display_name, is_local: false}.
    • POST /api/v1/auth/users/upsert-local — service token only. Body {username}. Returns local admin user record.
    • GET /api/v1/auth/users/{id} — service token only. Returns user, 404 otherwise.
  • Admin/MeController.php:
    • GET /api/v1/admin/me — admin token OR service+impersonation. Returns {user_id, email, display_name, role, source: "oidc"|"local"|"admin-token"}.

Wire routes with the appropriate middleware stack: TokenAuthenticationMiddlewareImpersonationMiddlewareRbacMiddleware::require(...).

6. CLI

Extend api/bin/console:

  • auth:bootstrap-service-token (described above).
  • auth:create-token --kind=admin --role=admin — creates an admin token (used in dev/manual ops). Outputs the raw token to stdout exactly once. Refuse to create service kind via this command.

7. Configuration

Add to api/config/settings.php:

  • UI_SERVICE_TOKEN (required when OIDC_DEFAULT_ROLEnone and the UI is in use; default behavior: warn if empty).
  • UI_ORIGIN for CORS (read but no enforcement yet — CORS middleware is a stretch task here; if you add it, be conservative).

Implementation notes

  • Token format and lookup: irdb_<kind3>_<base32> is parsed before DB lookup so a malformed token returns 401 without a DB hit. Hash the entire raw string (including prefix) for storage; the prefix is also stored separately for log readability.
  • Constant-time comparison: hash_equals() when comparing token hashes if you ever do an inline compare. For lookup-by-hash, the DB index is fine.
  • Don't leak which token kind was wrong. On any mismatch (bad kind for the route, expired, revoked), return a uniform 401 with body {"error":"unauthorized"}. Reserve 403 for "authenticated, wrong role."
  • Service token rotation: out of scope this milestone. The bootstrap just handles "set or not set." Document that rotating means: deploy with new value, restart api, manually revoke old hash via a future tool.
  • X-Acting-User-Id header: validate format (positive integer). Reject malformed with 400.
  • Audit log: not yet wired. Audit emitter lands in M12. Don't half-build it now.
  • Tests: this milestone lives or dies by test coverage. Aim for an integration test per cell of this matrix:
Token kind Has X-Acting-User-Id? User exists? Endpoint requires role Expected outcome
no token - - viewer 401
bad token - - viewer 401
reporter - - viewer 401 (wrong kind for admin route)
admin/viewer - - viewer 200
admin/viewer - - operator 403
admin/admin - - admin 200
service no - viewer 400
service yes no viewer 403
service yes (viewer user) yes viewer 200
service yes (viewer user) yes operator 403
service yes (admin user) yes admin 200

Out of scope (DO NOT)

  • No reporter/consumer endpoints (/api/v1/report, /api/v1/blocklist). M04.
  • No internal job endpoints. M05.
  • No rate limiting. M04.
  • No OIDC validation (the api doesn't validate OIDC tokens; the ui does, then calls upsert-oidc).
  • No password validation. The api never sees passwords.
  • No CORS enforcement (OK to add headers but don't gate on them yet).
  • No ui changes.
  • No audit log emission.
  • No new dependencies.

Acceptance

cd api && composer cs && composer stan && composer test && cd ..

# Service token bootstrap
docker compose down -v
cp .env.example .env
# Fill in: UI_SERVICE_TOKEN=<32 hex>, INTERNAL_JOB_TOKEN=<32 hex>, etc.
docker compose up -d
sleep 15
# Verify the token row exists
docker compose exec -T api sqlite3 /data/irdb.sqlite \
  "SELECT kind FROM api_tokens WHERE kind='service';" | grep -q "service"

# /api/v1/admin/me with bad auth
test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8081/api/v1/admin/me)" = "401"

# /api/v1/admin/me with admin token (created via CLI)
ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
RESP=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/me)
echo "$RESP" | grep -q '"role":"admin"'
echo "$RESP" | grep -q '"source":"admin-token"'

# Upsert local user via service token (simulating the ui)
SVC_TOKEN=$(grep ^UI_SERVICE_TOKEN= .env | cut -d= -f2)
RESP=$(curl -s -X POST \
  -H "Authorization: Bearer $SVC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"username":"admin"}' \
  http://localhost:8081/api/v1/auth/users/upsert-local)
USER_ID=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["user_id"];')
[ -n "$USER_ID" ]

# Service + impersonation: fetches /admin/me as that user
RESP=$(curl -s -H "Authorization: Bearer $SVC_TOKEN" -H "X-Acting-User-Id: $USER_ID" \
  http://localhost:8081/api/v1/admin/me)
echo "$RESP" | grep -q '"is_local":true'
echo "$RESP" | grep -q '"role":"admin"'

# Service without impersonation header → 400
test "$(curl -s -o /dev/null -w '%{http_code}' \
  -H "Authorization: Bearer $SVC_TOKEN" \
  http://localhost:8081/api/v1/admin/me)" = "400"

docker compose down -v

Handoff

  1. Commit:

    feat(M03): api auth foundations — tokens, RBAC, BFF impersonation
    
    - token kinds: reporter | consumer | admin | service (irdb_<kind>_<32b32> format)
    - TokenAuthenticationMiddleware + ImpersonationMiddleware + RbacMiddleware
    - /api/v1/auth/users/upsert-{oidc,local}, /api/v1/admin/me
    - service token bootstrap on container startup
    - integration tests cover the full auth matrix
    
  2. Append to PROGRESS.md:

    ## M03 — API auth foundations (done)
    
    **Built:** token kinds, hashing, RBAC, impersonation pattern, auth endpoints, service token bootstrap.
    
    **API contract decisions:**
    - 401 = bad/expired/revoked/wrong-kind token (uniform body)
    - 403 = authenticated but wrong role
    - 400 = service token without X-Acting-User-Id header
    - last_used_at updated synchronously (move to async in M14 if perf demands)
    
    **Notes for next milestone:**
    - Reporter and consumer tokens have no role column; their auth carries reporter_id / consumer_id only.
    - M04's report endpoint reads `principal->reporterId` from request attrs.
    - Admin endpoints in later milestones can use `RbacMiddleware::require(Role::Operator)` etc.
    
    **Deviations from SPEC:** none.
    **Added dependencies:** none.
    
  3. Stop. Do not start M04.