Fresh Claude Code agent prompt. M02 must be complete and committed. Estimated effort: large. This milestone is auth-critical; spend time on tests.
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.
Verify M02 acceptance:
git log --oneline -2 # M01, M02 commits
cd api && composer test && composer stan && cd ..
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).
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.
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.php — hash(string $raw): string using SHA-256 hex. Pure function, easy to test.In api/src/Infrastructure/Auth/:
TokenRepository.php — findByHash(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.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): ?UserfindBySubject(string): ?User (OIDC sub)findLocalByUsername(string): ?UserupsertOidc(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.php — resolveRole(array $groupIds, Role $default): Role.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:
X-Acting-User-Id header; missing → 400 Bad Request with {"error":"missing X-Acting-User-Id"}.403.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.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,
) {}
}
In api/src/Infrastructure/Auth/ServiceTokenBootstrap.php:
entrypoint.sh via a bin/console auth:bootstrap-service-token command, or run inline at app boot before HTTP serving).UI_SERVICE_TOKEN env var. If empty: log a warning, skip.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.
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: TokenAuthenticationMiddleware → ImpersonationMiddleware → RbacMiddleware::require(...).
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.Add to api/config/settings.php:
UI_SERVICE_TOKEN (required when OIDC_DEFAULT_ROLE ≠ none 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).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.hash_equals() when comparing token hashes if you ever do an inline compare. For lookup-by-hash, the DB index is fine.{"error":"unauthorized"}. Reserve 403 for "authenticated, wrong role."X-Acting-User-Id header: validate format (positive integer). Reject malformed with 400.| 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 |
/api/v1/report, /api/v1/blocklist). M04.upsert-oidc).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
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
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.
Stop. Do not start M04.