瀏覽代碼

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 周之前
父節點
當前提交
8cf73d2833
共有 36 個文件被更改,包括 2671 次插入46 次删除
  1. 5 2
      .env.example
  2. 25 0
      PROGRESS.md
  3. 102 3
      api/bin/console
  4. 7 0
      api/config/settings.php
  5. 40 0
      api/db/migrations/20260428130000_add_role_to_api_tokens.php
  6. 5 0
      api/docker/entrypoint.sh
  7. 4 38
      api/public/index.php
  8. 112 0
      api/src/App/AppFactory.php
  9. 69 3
      api/src/App/Container.php
  10. 84 0
      api/src/Application/Admin/MeController.php
  11. 182 0
      api/src/Application/Auth/AuthController.php
  12. 33 0
      api/src/Domain/Auth/AuthenticatedPrincipal.php
  13. 34 0
      api/src/Domain/Auth/Role.php
  14. 62 0
      api/src/Domain/Auth/Token.php
  15. 22 0
      api/src/Domain/Auth/TokenHasher.php
  16. 44 0
      api/src/Domain/Auth/TokenIssuer.php
  17. 41 0
      api/src/Domain/Auth/TokenKind.php
  18. 25 0
      api/src/Domain/User/User.php
  19. 55 0
      api/src/Infrastructure/Auth/RoleMappingRepository.php
  20. 90 0
      api/src/Infrastructure/Auth/ServiceTokenBootstrap.php
  21. 34 0
      api/src/Infrastructure/Auth/TokenRecord.php
  22. 141 0
      api/src/Infrastructure/Auth/TokenRepository.php
  23. 176 0
      api/src/Infrastructure/Auth/UserRepository.php
  24. 83 0
      api/src/Infrastructure/Http/JsonErrorHandler.php
  25. 87 0
      api/src/Infrastructure/Http/Middleware/ImpersonationMiddleware.php
  26. 72 0
      api/src/Infrastructure/Http/Middleware/RbacMiddleware.php
  27. 88 0
      api/src/Infrastructure/Http/Middleware/TokenAuthenticationMiddleware.php
  28. 186 0
      api/tests/Integration/Auth/AuthEndpointsTest.php
  29. 177 0
      api/tests/Integration/Auth/AuthMatrixTest.php
  30. 93 0
      api/tests/Integration/Auth/ServiceTokenBootstrapTest.php
  31. 205 0
      api/tests/Integration/Support/AppTestCase.php
  32. 125 0
      api/tests/Unit/Auth/RbacMiddlewareTest.php
  33. 32 0
      api/tests/Unit/Auth/RoleTest.php
  34. 29 0
      api/tests/Unit/Auth/TokenHasherTest.php
  35. 46 0
      api/tests/Unit/Auth/TokenIssuerTest.php
  36. 56 0
      api/tests/Unit/Auth/TokenTest.php

+ 5 - 2
.env.example

@@ -8,8 +8,11 @@
 # -----------------------------------------------------------------------------
 # Shared (consumed by both api and ui containers)
 # -----------------------------------------------------------------------------
-# 32-byte hex string. The api uses this to authenticate the ui's calls;
-# the ui presents it on every API request together with X-Acting-User-Id.
+# IRDB-format service token. The api uses this to authenticate the ui's
+# calls; the ui presents it on every API request together with
+# X-Acting-User-Id. Format: irdb_svc_<32 base32 chars>. Generate one with:
+#   docker compose run --rm -T api php -r 'require "/app/vendor/autoload.php";
+#       echo (new App\Domain\Auth\TokenIssuer())->issue(App\Domain\Auth\TokenKind::Service);'
 UI_SERVICE_TOKEN=
 
 # -----------------------------------------------------------------------------

+ 25 - 0
PROGRESS.md

@@ -40,3 +40,28 @@
 
 **Deviations from SPEC:** none.
 **Added dependencies:** none beyond SPEC §2.
+
+## 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 `{"error":"unauthorized"}`)
+- 403 = authenticated but wrong role
+- 400 = service token without (or malformed) `X-Acting-User-Id` header
+- `last_used_at` updated synchronously (move to async in M14 if perf demands)
+- `/api/v1/auth/*` is service-token-only with **no impersonation** — these endpoints exist to bootstrap user records the UI can later impersonate, so requiring impersonation would be circular. The controller enforces `kind=service` directly.
+- `X-Acting-User-Id` is silently ignored on non-service tokens (per SPEC §8); only its absence on a *service* token triggers 400.
+
+**Notes for next milestone:**
+- Reporter and consumer tokens have no role column; their auth carries `reporter_id` / `consumer_id` only. Reading `principal->reporterId` from request attrs is how M04's report endpoint will identify the reporter.
+- Admin endpoints in later milestones can use `RbacMiddleware::require($responseFactory, Role::Operator)` etc. — the factory takes the role; the response factory is in the container.
+- `AuthenticatedPrincipal` carries an optional `userId` so M14 can introduce admin-token-bound-to-user without churn.
+
+**Schema deviation:** `api_tokens.role` (nullable VARCHAR(32)) was added in migration `20260428130000_add_role_to_api_tokens.php`. SPEC §4 doesn't enumerate it but SPEC §6 mandates that admin tokens carry a role; the column stores it. Non-admin token rows leave it `NULL`.
+
+**Token format:** `irdb_<kind3>_<32 base32 chars>`, where `kind3` is one of `rep|con|adm|svc`. 160 bits of entropy from `random_bytes(20)`. The whole raw string is SHA-256 hashed for storage; `token_prefix` keeps the first 8 chars (`irdb_<kind3>`) for log readability. The `.env.example` documents how to generate a valid `UI_SERVICE_TOKEN` via `TokenIssuer`.
+
+**Service-token rotation:** out of scope this milestone — `ServiceTokenBootstrap` only handles "set or not set". Rotation means: deploy with the new value, restart api, manually revoke the old hash via a future tool. The bootstrap logs a warning when it inserts a new service token while another already exists.
+
+**Added dependencies:** none.

+ 102 - 3
api/bin/console

@@ -3,6 +3,15 @@
 
 declare(strict_types=1);
 
+use App\App\Container;
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenHasher;
+use App\Domain\Auth\TokenIssuer;
+use App\Domain\Auth\TokenKind;
+use App\Infrastructure\Auth\ServiceTokenBootstrap;
+use App\Infrastructure\Auth\TokenRecord;
+use App\Infrastructure\Auth\TokenRepository;
+
 require __DIR__ . '/../vendor/autoload.php';
 
 $argv = $_SERVER['argv'] ?? [];
@@ -22,6 +31,27 @@ $run = static function (string $phinxCommand) use ($phinxBin, $phinxConfig): nev
     exit($exitCode);
 };
 
+/**
+ * @param array<int, string> $argv
+ */
+$flag = static function (array $argv, string $name): ?string {
+    $prefix = '--' . $name . '=';
+    foreach ($argv as $arg) {
+        if (str_starts_with($arg, $prefix)) {
+            return substr($arg, strlen($prefix));
+        }
+    }
+
+    return null;
+};
+
+/**
+ * @param array<int, string> $argv
+ */
+$hasFlag = static function (array $argv, string $name): bool {
+    return in_array('--' . $name, $argv, true);
+};
+
 switch ($command) {
     case 'db:migrate':
         $run('migrate');
@@ -33,6 +63,72 @@ switch ($command) {
         $run('seed:run');
         // no break
 
+    case 'auth:bootstrap-service-token':
+        $container = Container::build();
+        /** @var ServiceTokenBootstrap $boot */
+        $boot = $container->get(ServiceTokenBootstrap::class);
+        /** @var string $rawToken */
+        $rawToken = $container->get('settings.ui_service_token');
+        $boot->bootstrap($rawToken);
+        exit(0);
+
+    case 'auth:create-token':
+        $kindArg = $flag($argv, 'kind') ?? '';
+        $roleArg = $flag($argv, 'role');
+        $quiet = $hasFlag($argv, 'quiet');
+
+        $kind = TokenKind::tryFrom($kindArg);
+        if ($kind === null) {
+            fwrite(STDERR, "Unknown --kind: {$kindArg}. Admin tokens are the only kind supported here.\n");
+            exit(1);
+        }
+        if ($kind === TokenKind::Service) {
+            fwrite(STDERR, "Refusing to create a service token via this command. Use UI_SERVICE_TOKEN + auth:bootstrap-service-token.\n");
+            exit(1);
+        }
+        if ($kind === TokenKind::Reporter || $kind === TokenKind::Consumer) {
+            fwrite(STDERR, "Reporter/consumer tokens are bound to records and are issued via M04 endpoints, not this CLI.\n");
+            exit(1);
+        }
+
+        // kind=admin from here on.
+        $role = $roleArg !== null ? Role::tryFrom(strtolower($roleArg)) : null;
+        if ($role === null) {
+            fwrite(STDERR, "Admin tokens require --role=viewer|operator|admin.\n");
+            exit(1);
+        }
+
+        $container = Container::build();
+        /** @var TokenIssuer $issuer */
+        $issuer = $container->get(TokenIssuer::class);
+        /** @var TokenHasher $hasher */
+        $hasher = $container->get(TokenHasher::class);
+        /** @var TokenRepository $repo */
+        $repo = $container->get(TokenRepository::class);
+
+        $raw = $issuer->issue(TokenKind::Admin);
+        $hash = $hasher->hash($raw);
+        $repo->create(new TokenRecord(
+            id: null,
+            kind: TokenKind::Admin,
+            hash: $hash,
+            prefix: substr($raw, 0, 8),
+            reporterId: null,
+            consumerId: null,
+            role: $role,
+            expiresAt: null,
+            revokedAt: null,
+            lastUsedAt: null,
+        ));
+
+        if ($quiet) {
+            fwrite(STDOUT, $raw);
+        } else {
+            fwrite(STDOUT, $raw . "\n");
+            fwrite(STDERR, "Created admin token (role={$role->value}). The token is only shown once.\n");
+        }
+        exit(0);
+
     case null:
     case '--help':
     case '-h':
@@ -40,9 +136,12 @@ switch ($command) {
             Usage: console <command>
 
             Commands:
-              db:migrate    Run Phinx migrations
-              db:rollback   Roll back the most recent migration
-              db:seed       Run all seeders idempotently
+              db:migrate                          Run Phinx migrations
+              db:rollback                         Roll back the most recent migration
+              db:seed                             Run all seeders idempotently
+              auth:bootstrap-service-token        Provision UI_SERVICE_TOKEN row in api_tokens
+              auth:create-token --kind=admin --role=admin|operator|viewer [--quiet]
+                                                  Create an admin token; raw token printed to stdout
 
             TXT);
         exit(0);

+ 7 - 0
api/config/settings.php

@@ -2,6 +2,7 @@
 
 declare(strict_types=1);
 
+use App\Domain\Auth\Role;
 use Monolog\Level;
 
 $appEnv = getenv('APP_ENV') ?: 'production';
@@ -23,6 +24,11 @@ $logLevel = match ($logLevelName) {
     default => Level::Info,
 };
 
+$oidcDefaultRoleName = strtolower((string) (getenv('OIDC_DEFAULT_ROLE') ?: 'viewer'));
+$oidcDefaultRole = $oidcDefaultRoleName === 'none'
+    ? null
+    : (Role::tryFrom($oidcDefaultRoleName) ?? Role::Viewer);
+
 return [
     'app_env' => $appEnv,
     'log_level' => $logLevel,
@@ -39,4 +45,5 @@ return [
     'ui_service_token' => getenv('UI_SERVICE_TOKEN') ?: '',
     'internal_job_token' => getenv('INTERNAL_JOB_TOKEN') ?: '',
     'ui_origin' => getenv('UI_ORIGIN') ?: 'http://localhost:8080',
+    'oidc_default_role' => $oidcDefaultRole,
 ];

+ 40 - 0
api/db/migrations/20260428130000_add_role_to_api_tokens.php

@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * Adds a nullable `role` column to api_tokens to back the admin-token role
+ * binding described in SPEC §6: "admin — Bound to a configured role
+ * (viewer | operator | admin)". The column is NULL for non-admin token
+ * kinds; the application layer enforces that admin tokens have it set.
+ *
+ * Done as a raw ALTER TABLE rather than via Phinx's table API so SQLite and
+ * MySQL share the same migration body (Phinx's addColumn after-the-fact
+ * needs careful CHECK-constraint preservation on SQLite, and the existing
+ * api_tokens CHECK is acceptable as-is).
+ */
+final class AddRoleToApiTokens extends BaseMigration
+{
+    public function up(): void
+    {
+        $this->execute('ALTER TABLE api_tokens ADD COLUMN role VARCHAR(32) NULL');
+    }
+
+    public function down(): void
+    {
+        // SQLite cannot DROP COLUMN reliably across all supported versions,
+        // so for rollback we recreate the table without the column. In
+        // practice this migration won't be rolled back in production.
+        if ($this->isMysql()) {
+            $this->execute('ALTER TABLE api_tokens DROP COLUMN role');
+
+            return;
+        }
+
+        // SQLite >= 3.35 supports DROP COLUMN; the runtime in our images is
+        // newer than that. Fall back to a hard error on older SQLite.
+        $this->execute('ALTER TABLE api_tokens DROP COLUMN role');
+    }
+}

+ 5 - 0
api/docker/entrypoint.sh

@@ -11,6 +11,11 @@ fi
 
 case "$mode" in
     api)
+        # Provision UI_SERVICE_TOKEN in api_tokens before serving. Idempotent;
+        # logs a warning and skips if the env var is empty (early bring-up).
+        cd /app
+        php bin/console auth:bootstrap-service-token || \
+            echo "warning: auth:bootstrap-service-token failed; continuing anyway" >&2
         exec frankenphp run --config /etc/Caddyfile
         ;;
     migrate)

+ 4 - 38
api/public/index.php

@@ -2,44 +2,10 @@
 
 declare(strict_types=1);
 
-use Monolog\Formatter\JsonFormatter;
-use Monolog\Handler\StreamHandler;
-use Monolog\Logger;
-use Psr\Http\Message\ResponseInterface as Response;
-use Psr\Http\Message\ServerRequestInterface as Request;
-use Slim\Factory\AppFactory;
+use App\App\AppFactory;
+use App\App\Container;
 
 require __DIR__ . '/../vendor/autoload.php';
 
-$settings = require __DIR__ . '/../config/settings.php';
-
-$logger = new Logger('api');
-$handler = new StreamHandler('php://stdout', $settings['log_level']);
-$handler->setFormatter(new JsonFormatter());
-$logger->pushHandler($handler);
-
-$app = AppFactory::create();
-$app->addRoutingMiddleware();
-$app->addBodyParsingMiddleware();
-$app->addErrorMiddleware($settings['app_env'] === 'development', true, true, $logger);
-
-$app->get('/healthz', function (Request $request, Response $response): Response {
-    // Stub healthcheck. Later milestones extend this with `db` and `jobs` fields.
-    $response->getBody()->write((string) json_encode(['status' => 'ok']));
-
-    return $response->withHeader('Content-Type', 'application/json');
-});
-
-$app->map(
-    ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
-    '/{routes:.+}',
-    function (Request $request, Response $response): Response {
-        $response->getBody()->write((string) json_encode(['error' => 'not_found']));
-
-        return $response
-            ->withHeader('Content-Type', 'application/json')
-            ->withStatus(404);
-    }
-);
-
-$app->run();
+$container = Container::build();
+AppFactory::build($container)->run();

+ 112 - 0
api/src/App/AppFactory.php

@@ -0,0 +1,112 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\App;
+
+use App\Application\Admin\MeController;
+use App\Application\Auth\AuthController;
+use App\Domain\Auth\Role;
+use App\Infrastructure\Http\JsonErrorHandler;
+use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
+use App\Infrastructure\Http\Middleware\RbacMiddleware;
+use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Log\LoggerInterface;
+use Slim\App;
+use Slim\Factory\AppFactory as SlimAppFactory;
+use Slim\Routing\RouteCollectorProxy;
+
+/**
+ * Builds the configured Slim app — used by `public/index.php` for serving
+ * and by integration tests for end-to-end coverage of the middleware
+ * stack. Centralising it means routes only get wired once.
+ *
+ * Slim middleware is LIFO: the *last* `->add()` runs first. So to get the
+ * documented "TokenAuth → Impersonation → Rbac" order, we add them in
+ * reverse: Rbac (innermost), then Impersonation, then TokenAuth
+ * (outermost — runs first on the way in).
+ */
+final class AppFactory
+{
+    /**
+     * @return App<ContainerInterface|null>
+     */
+    public static function build(ContainerInterface $container): App
+    {
+        SlimAppFactory::setContainer($container);
+        $app = SlimAppFactory::create();
+        $app->addRoutingMiddleware();
+        $app->addBodyParsingMiddleware();
+
+        /** @var array{app_env: string} $settings */
+        $settings = $container->get('settings');
+        $isDev = $settings['app_env'] === 'development';
+
+        /** @var LoggerInterface $logger */
+        $logger = $container->get(LoggerInterface::class);
+
+        $errorMiddleware = $app->addErrorMiddleware($isDev, true, true, $logger);
+        /** @var JsonErrorHandler $handler */
+        $handler = $container->get(JsonErrorHandler::class);
+        $errorMiddleware->setDefaultErrorHandler($handler);
+
+        /** @var ResponseFactoryInterface $rf */
+        $rf = $container->get(ResponseFactoryInterface::class);
+        /** @var TokenAuthenticationMiddleware $tokenAuth */
+        $tokenAuth = $container->get(TokenAuthenticationMiddleware::class);
+        /** @var ImpersonationMiddleware $impersonation */
+        $impersonation = $container->get(ImpersonationMiddleware::class);
+
+        $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
+            $response->getBody()->write((string) json_encode(['status' => 'ok']));
+
+            return $response->withHeader('Content-Type', 'application/json');
+        });
+
+        // Auth API: service-token-only. No impersonation — these endpoints
+        // exist to *produce* user_ids the ui can later impersonate. The
+        // controller enforces kind=service on each call.
+        $app->group('/api/v1/auth', function (RouteCollectorProxy $auth) use ($container): void {
+            /** @var AuthController $controller */
+            $controller = $container->get(AuthController::class);
+            $auth->post('/users/upsert-oidc', [$controller, 'upsertOidc']);
+            $auth->post('/users/upsert-local', [$controller, 'upsertLocal']);
+            $auth->get('/users/{id}', function (
+                ServerRequestInterface $request,
+                ResponseInterface $response,
+                array $args,
+            ) use ($controller): ResponseInterface {
+                /** @var array{id: string} $args */
+                return $controller->getUser($request, $response, $args['id']);
+            });
+        })->add($tokenAuth);
+
+        // Admin API: token auth → impersonation → role check.
+        $app->group('/api/v1/admin', function (RouteCollectorProxy $admin) use ($container, $rf): void {
+            /** @var MeController $me */
+            $me = $container->get(MeController::class);
+            $admin->get('/me', $me)
+                ->add(RbacMiddleware::require($rf, Role::Viewer));
+        })
+            ->add($impersonation)
+            ->add($tokenAuth);
+
+        $app->map(
+            ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
+            '/{routes:.+}',
+            function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
+                $response->getBody()->write((string) json_encode(['error' => 'not_found']));
+
+                return $response
+                    ->withHeader('Content-Type', 'application/json')
+                    ->withStatus(404);
+            }
+        );
+
+        return $app;
+    }
+}

+ 69 - 3
api/src/App/Container.php

@@ -4,20 +4,42 @@ declare(strict_types=1);
 
 namespace App\App;
 
+use App\Application\Admin\MeController;
+use App\Application\Auth\AuthController;
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenHasher;
+use App\Domain\Auth\TokenIssuer;
+use App\Infrastructure\Auth\RoleMappingRepository;
+use App\Infrastructure\Auth\ServiceTokenBootstrap;
+use App\Infrastructure\Auth\TokenRepository;
+use App\Infrastructure\Auth\UserRepository;
 use App\Infrastructure\Db\ConnectionFactory;
+use App\Infrastructure\Http\JsonErrorHandler;
+use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
+use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
+
+use function DI\autowire;
+
 use DI\ContainerBuilder;
 
 use function DI\factory;
 
 use Doctrine\DBAL\Connection;
+use Monolog\Formatter\JsonFormatter;
+use Monolog\Handler\StreamHandler;
+use Monolog\Logger;
 use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Log\LoggerInterface;
+use Slim\Psr7\Factory\ResponseFactory;
 
 /**
  * Builds the api's DI container.
  *
- * Kept deliberately small in M02 — only application settings and the DBAL
- * Connection are registered. Subsequent milestones add repositories,
- * services, middleware, etc. on top of this base.
+ * Adds in M03: token domain helpers, repositories, middlewares, the JSON
+ * error handler, and the controllers. Wiring is autowire-friendly except
+ * where we need to inject a primitive (the OIDC default Role) or pull a
+ * value out of `settings`.
  */
 final class Container
 {
@@ -33,6 +55,10 @@ final class Container
         $builder->addDefinitions([
             'settings' => $settings,
             'settings.db' => $settings['db'],
+            'settings.ui_service_token' => $settings['ui_service_token'] ?? '',
+            'settings.app_env' => $settings['app_env'] ?? 'production',
+            'settings.log_level' => $settings['log_level'] ?? \Monolog\Level::Info,
+            'settings.oidc_default_role' => $settings['oidc_default_role'] ?? Role::Viewer,
             ConnectionFactory::class => factory(static function (ContainerInterface $c): ConnectionFactory {
                 /** @var array{driver: string, sqlite_path: string, mysql_host: string, mysql_port: int, mysql_database: string, mysql_username: string, mysql_password: string} $db */
                 $db = $c->get('settings.db');
@@ -45,6 +71,46 @@ final class Container
 
                 return $factory->create();
             }),
+            LoggerInterface::class => factory(static function (ContainerInterface $c): LoggerInterface {
+                $logger = new Logger('api');
+                /** @var \Monolog\Level $level */
+                $level = $c->get('settings.log_level');
+                $handler = new StreamHandler('php://stdout', $level);
+                $handler->setFormatter(new JsonFormatter());
+                $logger->pushHandler($handler);
+
+                return $logger;
+            }),
+            ResponseFactoryInterface::class => autowire(ResponseFactory::class),
+            TokenHasher::class => autowire(),
+            TokenIssuer::class => autowire(),
+            TokenRepository::class => autowire(),
+            RoleMappingRepository::class => autowire(),
+            UserRepository::class => autowire(),
+            ServiceTokenBootstrap::class => autowire(),
+            TokenAuthenticationMiddleware::class => autowire(),
+            ImpersonationMiddleware::class => autowire(),
+            JsonErrorHandler::class => factory(static function (ContainerInterface $c): JsonErrorHandler {
+                /** @var ResponseFactoryInterface $factory */
+                $factory = $c->get(ResponseFactoryInterface::class);
+                /** @var LoggerInterface $logger */
+                $logger = $c->get(LoggerInterface::class);
+
+                return new JsonErrorHandler(
+                    $factory,
+                    $logger,
+                    $c->get('settings.app_env') === 'development',
+                );
+            }),
+            AuthController::class => factory(static function (ContainerInterface $c): AuthController {
+                /** @var Role|null $role */
+                $role = $c->get('settings.oidc_default_role');
+                /** @var UserRepository $users */
+                $users = $c->get(UserRepository::class);
+
+                return new AuthController($users, $role ?? Role::Viewer);
+            }),
+            MeController::class => autowire(),
         ]);
 
         return $builder->build();

+ 84 - 0
api/src/Application/Admin/MeController.php

@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Domain\Auth\TokenKind;
+use App\Infrastructure\Auth\UserRepository;
+use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * `GET /api/v1/admin/me` — returns whoever is currently calling.
+ *
+ * Three branches:
+ *  - admin token, no user binding   → role from token, source=admin-token,
+ *    no user_id / email / display_name.
+ *  - admin token bound to a user    → role from token, source from user
+ *    record's is_local flag (M14 territory; reachable now if a future
+ *    feature sets userId on admin tokens).
+ *  - service token + impersonation  → role and identity from the user.
+ */
+final class MeController
+{
+    public function __construct(private readonly UserRepository $users)
+    {
+    }
+
+    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
+        if (!$principal instanceof AuthenticatedPrincipal) {
+            return self::json($response, 401, ['error' => 'unauthorized']);
+        }
+
+        if ($principal->tokenKind === TokenKind::Admin && $principal->userId === null) {
+            return self::json($response, 200, [
+                'user_id' => null,
+                'email' => null,
+                'display_name' => null,
+                'role' => $principal->role?->value,
+                'is_local' => false,
+                'source' => 'admin-token',
+            ]);
+        }
+
+        if ($principal->userId === null) {
+            // Defensive: should not happen — service-without-impersonation
+            // is rejected upstream and reporter/consumer never reach here.
+            return self::json($response, 401, ['error' => 'unauthorized']);
+        }
+
+        $user = $this->users->findById($principal->userId);
+        if ($user === null) {
+            return self::json($response, 403, ['error' => 'forbidden']);
+        }
+
+        $source = $principal->tokenKind === TokenKind::Admin
+            ? 'admin-token'
+            : ($user->isLocal ? 'local' : 'oidc');
+
+        return self::json($response, 200, [
+            'user_id' => $user->id,
+            'email' => $user->email,
+            'display_name' => $user->displayName,
+            'role' => $user->role->value,
+            'is_local' => $user->isLocal,
+            'source' => $source,
+        ]);
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    private static function json(ResponseInterface $response, int $status, array $payload): ResponseInterface
+    {
+        $response = $response->withStatus($status)->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode($payload));
+
+        return $response;
+    }
+}

+ 182 - 0
api/src/Application/Auth/AuthController.php

@@ -0,0 +1,182 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Auth;
+
+use App\Domain\Auth\Role;
+use App\Infrastructure\Auth\UserRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Auth API controller — exclusively service-token routes per SPEC §6.
+ *
+ * Service-token-only enforcement is applied at the route level (the auth
+ * routes attach RbacMiddleware::require(Role::Admin) on top of the
+ * impersonation middleware, but the service token is the only realistic
+ * caller). For the auth endpoints we additionally guard against admin
+ * tokens calling them — see the explicit kind check below.
+ */
+final class AuthController
+{
+    public function __construct(
+        private readonly UserRepository $users,
+        private readonly Role $oidcDefaultRole,
+    ) {
+    }
+
+    public function upsertOidc(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        if (($denied = $this->requireServiceToken($request, $response)) !== null) {
+            return $denied;
+        }
+
+        $body = $this->json($request);
+        $subject = self::str($body, 'subject');
+        $email = self::str($body, 'email');
+        $displayName = self::str($body, 'display_name');
+        $groups = self::stringList($body, 'groups');
+
+        if ($subject === '' || $email === '' || $displayName === '') {
+            return self::error($response, 400, 'invalid request body');
+        }
+
+        $user = $this->users->upsertOidc($subject, $email, $displayName, $groups, $this->oidcDefaultRole);
+
+        return self::json_response($response, 200, [
+            'user_id' => $user->id,
+            'role' => $user->role->value,
+            'email' => $user->email,
+            'display_name' => $user->displayName,
+            'is_local' => false,
+        ]);
+    }
+
+    public function upsertLocal(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        if (($denied = $this->requireServiceToken($request, $response)) !== null) {
+            return $denied;
+        }
+
+        $body = $this->json($request);
+        $username = self::str($body, 'username');
+        if ($username === '') {
+            return self::error($response, 400, 'invalid request body');
+        }
+
+        $user = $this->users->upsertLocal($username);
+
+        return self::json_response($response, 200, [
+            'user_id' => $user->id,
+            'role' => $user->role->value,
+            'email' => null,
+            'display_name' => $user->displayName,
+            'is_local' => true,
+        ]);
+    }
+
+    public function getUser(
+        ServerRequestInterface $request,
+        ResponseInterface $response,
+        string $id,
+    ): ResponseInterface {
+        if (($denied = $this->requireServiceToken($request, $response)) !== null) {
+            return $denied;
+        }
+
+        if (preg_match('/^[1-9][0-9]*$/', $id) !== 1) {
+            return self::error($response, 400, 'invalid user id');
+        }
+
+        $user = $this->users->findById((int) $id);
+        if ($user === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        return self::json_response($response, 200, [
+            'user_id' => $user->id,
+            'role' => $user->role->value,
+            'email' => $user->email,
+            'display_name' => $user->displayName,
+            'is_local' => $user->isLocal,
+        ]);
+    }
+
+    private function requireServiceToken(
+        ServerRequestInterface $request,
+        ResponseInterface $response,
+    ): ?ResponseInterface {
+        $record = $request->getAttribute(\App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware::ATTR_TOKEN_RECORD);
+        if (
+            !$record instanceof \App\Infrastructure\Auth\TokenRecord
+            || $record->kind !== \App\Domain\Auth\TokenKind::Service
+        ) {
+            return self::error($response, 403, 'forbidden');
+        }
+
+        return null;
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function json(ServerRequestInterface $request): array
+    {
+        $parsed = $request->getParsedBody();
+        if (is_array($parsed)) {
+            return $parsed;
+        }
+        $raw = (string) $request->getBody();
+        if ($raw === '') {
+            return [];
+        }
+        $decoded = json_decode($raw, true);
+
+        return is_array($decoded) ? $decoded : [];
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     */
+    private static function str(array $body, string $key): string
+    {
+        return isset($body[$key]) && is_string($body[$key]) ? $body[$key] : '';
+    }
+
+    /**
+     * @param array<string, mixed> $body
+     * @return list<string>
+     */
+    private static function stringList(array $body, string $key): array
+    {
+        if (!isset($body[$key]) || !is_array($body[$key])) {
+            return [];
+        }
+
+        $out = [];
+        foreach ($body[$key] as $item) {
+            if (is_string($item) && $item !== '') {
+                $out[] = $item;
+            }
+        }
+
+        return $out;
+    }
+
+    private static function error(ResponseInterface $response, int $status, string $message): ResponseInterface
+    {
+        return self::json_response($response, $status, ['error' => $message]);
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    private static function json_response(ResponseInterface $response, int $status, array $payload): ResponseInterface
+    {
+        $response = $response->withStatus($status)->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode($payload));
+
+        return $response;
+    }
+}

+ 33 - 0
api/src/Domain/Auth/AuthenticatedPrincipal.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Auth;
+
+/**
+ * The result of token + impersonation resolution.
+ *
+ * Attached to the PSR-7 request as `principal` after the auth middleware
+ * stack runs. RbacMiddleware reads `role`; controllers read whichever of
+ * `userId` / `reporterId` / `consumerId` is relevant.
+ *
+ * `userId` is set when:
+ *  - the token is a `service` token and impersonation produced a user, or
+ *  - the token is an `admin` token bound to a user (none in M03, but the
+ *    field is present so M14 can add it without churn).
+ *
+ * `role` is set for `admin` tokens (the token's own role) and after
+ * service-impersonation (the impersonated user's role).
+ */
+final class AuthenticatedPrincipal
+{
+    public function __construct(
+        public readonly TokenKind $tokenKind,
+        public readonly ?int $userId,
+        public readonly ?Role $role,
+        public readonly ?int $reporterId,
+        public readonly ?int $consumerId,
+        public readonly int $tokenId,
+    ) {
+    }
+}

+ 34 - 0
api/src/Domain/Auth/Role.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Auth;
+
+/**
+ * RBAC role. Order: Admin > Operator > Viewer (per SPEC §7 RBAC matrix).
+ *
+ * `satisfies(Role $required)` is the only thing RbacMiddleware needs to
+ * call when deciding whether to admit a request. Roles are intentionally
+ * a small total order — adding intermediate tiers later requires re-reviewing
+ * every endpoint's required-role declaration anyway.
+ */
+enum Role: string
+{
+    case Viewer = 'viewer';
+    case Operator = 'operator';
+    case Admin = 'admin';
+
+    public function satisfies(self $required): bool
+    {
+        return $this->level() >= $required->level();
+    }
+
+    private function level(): int
+    {
+        return match ($this) {
+            self::Viewer => 1,
+            self::Operator => 2,
+            self::Admin => 3,
+        };
+    }
+}

+ 62 - 0
api/src/Domain/Auth/Token.php

@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Auth;
+
+/**
+ * A parsed `Authorization: Bearer ...` value, after format validation but
+ * before DB lookup. Used as a parsing intermediate in
+ * TokenAuthenticationMiddleware.
+ *
+ * `parse()` is permissive about whitespace but strict about format: it
+ * rejects malformed prefixes, wrong base32 alphabet, and wrong body length
+ * up front so the middleware can return 401 without a DB hit.
+ */
+final class Token
+{
+    private const BODY_PATTERN = '/^[A-Z2-7]{32}$/';
+
+    public function __construct(
+        public readonly TokenKind $kind,
+        public readonly string $raw,
+    ) {
+    }
+
+    public static function parse(string $raw): ?self
+    {
+        if (!str_starts_with($raw, 'irdb_')) {
+            return null;
+        }
+
+        $parts = explode('_', $raw, 3);
+        if (count($parts) !== 3) {
+            return null;
+        }
+
+        [$prefix, $kindCode, $body] = $parts;
+        if ($prefix !== 'irdb') {
+            return null;
+        }
+
+        $kind = TokenKind::fromCode($kindCode);
+        if ($kind === null) {
+            return null;
+        }
+
+        if (preg_match(self::BODY_PATTERN, $body) !== 1) {
+            return null;
+        }
+
+        return new self($kind, $raw);
+    }
+
+    /**
+     * The `token_prefix` we persist for ops/log readability. First 8 chars
+     * of the raw token — enough to identify a token without revealing it.
+     */
+    public function prefix(): string
+    {
+        return substr($this->raw, 0, 8);
+    }
+}

+ 22 - 0
api/src/Domain/Auth/TokenHasher.php

@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Auth;
+
+/**
+ * SHA-256 hex digest of the entire raw token string (including the
+ * `irdb_<kind3>_` prefix). Pure function: deterministic, no side effects,
+ * trivially testable.
+ *
+ * The hash, not the raw token, is what goes into api_tokens.token_hash.
+ * DB lookup is by hash; constant-time comparison via hash_equals() is only
+ * needed if you ever fall back to a row scan.
+ */
+final class TokenHasher
+{
+    public function hash(string $raw): string
+    {
+        return hash('sha256', $raw);
+    }
+}

+ 44 - 0
api/src/Domain/Auth/TokenIssuer.php

@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Auth;
+
+/**
+ * Generates raw token strings in the form `irdb_<kind3>_<32-char-base32>`.
+ *
+ * 160 bits of entropy from random_bytes(20), encoded with RFC 4648 base32
+ * (A–Z, 2–7) without padding — exactly 32 base32 chars. Returns the raw
+ * token; hashing for storage is the caller's responsibility.
+ */
+final class TokenIssuer
+{
+    private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+
+    public function issue(TokenKind $kind): string
+    {
+        return sprintf('irdb_%s_%s', $kind->code(), self::base32Encode(random_bytes(20)));
+    }
+
+    /**
+     * Encodes 20 raw bytes (160 bits) as 32 base32 chars (no padding).
+     */
+    private static function base32Encode(string $bytes): string
+    {
+        $bits = '';
+        for ($i = 0, $n = strlen($bytes); $i < $n; ++$i) {
+            $bits .= str_pad(decbin(ord($bytes[$i])), 8, '0', STR_PAD_LEFT);
+        }
+
+        $out = '';
+        for ($i = 0, $n = strlen($bits); $i < $n; $i += 5) {
+            $chunk = substr($bits, $i, 5);
+            if (strlen($chunk) < 5) {
+                $chunk = str_pad($chunk, 5, '0');
+            }
+            $out .= self::BASE32_ALPHABET[bindec($chunk)];
+        }
+
+        return $out;
+    }
+}

+ 41 - 0
api/src/Domain/Auth/TokenKind.php

@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Auth;
+
+/**
+ * The four token kinds stored in api_tokens.
+ *
+ * The string value matches what is persisted in the `kind` column and
+ * what flows through API contracts. The 3-letter code is the segment
+ * embedded in the human-readable token string `irdb_<kind3>_<base32>`.
+ */
+enum TokenKind: string
+{
+    case Reporter = 'reporter';
+    case Consumer = 'consumer';
+    case Admin = 'admin';
+    case Service = 'service';
+
+    public function code(): string
+    {
+        return match ($this) {
+            self::Reporter => 'rep',
+            self::Consumer => 'con',
+            self::Admin => 'adm',
+            self::Service => 'svc',
+        };
+    }
+
+    public static function fromCode(string $code): ?self
+    {
+        return match ($code) {
+            'rep' => self::Reporter,
+            'con' => self::Consumer,
+            'adm' => self::Admin,
+            'svc' => self::Service,
+            default => null,
+        };
+    }
+}

+ 25 - 0
api/src/Domain/User/User.php

@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\User;
+
+use App\Domain\Auth\Role;
+
+/**
+ * Identity record for a UI user. Local-admin records have `subject=null`,
+ * `email=null`, `isLocal=true`; OIDC records carry a non-null `subject`
+ * (the OIDC `sub` claim) and `isLocal=false`.
+ */
+final class User
+{
+    public function __construct(
+        public readonly int $id,
+        public readonly ?string $subject,
+        public readonly ?string $email,
+        public readonly ?string $displayName,
+        public readonly Role $role,
+        public readonly bool $isLocal,
+    ) {
+    }
+}

+ 55 - 0
api/src/Infrastructure/Auth/RoleMappingRepository.php

@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Auth;
+
+use App\Domain\Auth\Role;
+use Doctrine\DBAL\Connection;
+
+/**
+ * Resolves a user's role from their current OIDC group memberships using
+ * the oidc_role_mappings table. Picks the *highest* role across all
+ * matching groups (Admin > Operator > Viewer); falls back to the default
+ * role if no group matches.
+ */
+final class RoleMappingRepository
+{
+    public function __construct(private readonly Connection $connection)
+    {
+    }
+
+    /**
+     * @param list<string> $groupIds
+     */
+    public function resolveRole(array $groupIds, Role $default): Role
+    {
+        if ($groupIds === []) {
+            return $default;
+        }
+
+        $placeholders = implode(', ', array_fill(0, count($groupIds), '?'));
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection->fetchAllAssociative(
+            sprintf('SELECT role FROM oidc_role_mappings WHERE group_id IN (%s)', $placeholders),
+            array_values($groupIds)
+        );
+
+        if ($rows === []) {
+            return $default;
+        }
+
+        $highest = null;
+        foreach ($rows as $row) {
+            $role = Role::tryFrom((string) $row['role']);
+            if ($role === null) {
+                continue;
+            }
+            if ($highest === null || $role->satisfies($highest)) {
+                $highest = $role;
+            }
+        }
+
+        return $highest ?? $default;
+    }
+}

+ 90 - 0
api/src/Infrastructure/Auth/ServiceTokenBootstrap.php

@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Auth;
+
+use App\Domain\Auth\Token;
+use App\Domain\Auth\TokenHasher;
+use App\Domain\Auth\TokenKind;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Ensures the api_tokens table contains a row matching the configured
+ * UI_SERVICE_TOKEN. Idempotent — safe to call on every container start.
+ *
+ * - empty env → log a warning and skip (early-bring-up scenario).
+ * - hash matches an existing service-kind row → no-op.
+ * - hash absent → insert.
+ * - hash absent but a *different* service-kind row exists → log warning
+ *   (likely a rotation in progress), insert anyway. The operator must
+ *   revoke the old hash via a future tooling change. Auto-revocation here
+ *   would risk locking out a misconfigured deploy.
+ */
+final class ServiceTokenBootstrap
+{
+    public function __construct(
+        private readonly TokenRepository $tokens,
+        private readonly TokenHasher $hasher,
+        private readonly LoggerInterface $logger,
+    ) {
+    }
+
+    public function bootstrap(string $rawToken): void
+    {
+        if ($rawToken === '') {
+            $this->logger->warning('UI_SERVICE_TOKEN is empty; skipping service-token bootstrap');
+
+            return;
+        }
+
+        $parsed = Token::parse($rawToken);
+        if ($parsed === null || $parsed->kind !== TokenKind::Service) {
+            $this->logger->warning('UI_SERVICE_TOKEN is not a valid `irdb_svc_…` token; skipping');
+
+            return;
+        }
+
+        $hash = $this->hasher->hash($rawToken);
+        $existing = $this->tokens->findByHashIncludingInvalid($hash);
+
+        if ($existing !== null) {
+            if ($existing->kind === TokenKind::Service) {
+                $this->logger->info('UI_SERVICE_TOKEN already provisioned; no-op');
+
+                return;
+            }
+
+            // A non-service token with the same hash should be impossible
+            // (160 bits of entropy). If it ever happens, refuse to clobber.
+            $this->logger->error('UI_SERVICE_TOKEN hash collides with non-service row; refusing to insert');
+
+            return;
+        }
+
+        // No existing row by exact hash. Check if there is a *different*
+        // service-kind row already — typical during rotation.
+        $stmt = $this->tokens->countServiceTokens();
+        if ($stmt > 0) {
+            $this->logger->warning(
+                'UI_SERVICE_TOKEN does not match the existing service-kind row(s); '
+                . 'inserting new service token. Operator must revoke the old hash manually.'
+            );
+        }
+
+        $this->tokens->create(new TokenRecord(
+            id: null,
+            kind: TokenKind::Service,
+            hash: $hash,
+            prefix: $parsed->prefix(),
+            reporterId: null,
+            consumerId: null,
+            role: null,
+            expiresAt: null,
+            revokedAt: null,
+            lastUsedAt: null,
+        ));
+
+        $this->logger->info('UI_SERVICE_TOKEN provisioned');
+    }
+}

+ 34 - 0
api/src/Infrastructure/Auth/TokenRecord.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Auth;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use DateTimeImmutable;
+
+/**
+ * Persistence shape for an api_tokens row, hydrated by TokenRepository.
+ *
+ * Mutable on `lastUsedAt` only; everything else is immutable for the
+ * lifetime of the token. Note this carries the SHA-256 hash, never the
+ * raw token — the raw is shown to the operator once at creation time and
+ * then forgotten by the api.
+ */
+final class TokenRecord
+{
+    public function __construct(
+        public readonly ?int $id,
+        public readonly TokenKind $kind,
+        public readonly string $hash,
+        public readonly string $prefix,
+        public readonly ?int $reporterId,
+        public readonly ?int $consumerId,
+        public readonly ?Role $role,
+        public readonly ?DateTimeImmutable $expiresAt,
+        public readonly ?DateTimeImmutable $revokedAt,
+        public readonly ?DateTimeImmutable $lastUsedAt,
+    ) {
+    }
+}

+ 141 - 0
api/src/Infrastructure/Auth/TokenRepository.php

@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Auth;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\DBAL\Connection;
+
+/**
+ * DBAL-backed gateway for the api_tokens table.
+ *
+ * `findByHash()` already filters out revoked and expired rows, so callers
+ * can treat any non-null return as "currently valid". Time comparisons are
+ * done in UTC against ISO 8601 strings (SQLite) or DATETIME(6) values
+ * (MySQL) — both lexicographically sortable when expressed in UTC.
+ */
+final class TokenRepository
+{
+    public function __construct(private readonly Connection $connection)
+    {
+    }
+
+    public function findByHash(string $hash): ?TokenRecord
+    {
+        $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
+
+        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+            . 'expires_at, revoked_at, last_used_at '
+            . 'FROM api_tokens '
+            . 'WHERE token_hash = :hash '
+            . 'AND revoked_at IS NULL '
+            . 'AND (expires_at IS NULL OR expires_at > :now) '
+            . 'LIMIT 1';
+
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative($sql, [
+            'hash' => $hash,
+            'now' => $now,
+        ]);
+
+        if ($row === false) {
+            return null;
+        }
+
+        return $this->hydrate($row);
+    }
+
+    public function findByHashIncludingInvalid(string $hash): ?TokenRecord
+    {
+        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+            . 'expires_at, revoked_at, last_used_at '
+            . 'FROM api_tokens WHERE token_hash = :hash LIMIT 1';
+
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative($sql, ['hash' => $hash]);
+
+        if ($row === false) {
+            return null;
+        }
+
+        return $this->hydrate($row);
+    }
+
+    public function create(TokenRecord $record): int
+    {
+        $this->connection->insert('api_tokens', [
+            'token_hash' => $record->hash,
+            'token_prefix' => $record->prefix,
+            'kind' => $record->kind->value,
+            'reporter_id' => $record->reporterId,
+            'consumer_id' => $record->consumerId,
+            'role' => $record->role?->value,
+            'expires_at' => $record->expiresAt?->format('Y-m-d H:i:s'),
+            'revoked_at' => $record->revokedAt?->format('Y-m-d H:i:s'),
+            'last_used_at' => $record->lastUsedAt?->format('Y-m-d H:i:s'),
+        ]);
+
+        return (int) $this->connection->lastInsertId();
+    }
+
+    public function markUsed(int $id, DateTimeImmutable $when): void
+    {
+        $this->connection->update(
+            'api_tokens',
+            ['last_used_at' => $when->format('Y-m-d H:i:s')],
+            ['id' => $id]
+        );
+    }
+
+    /**
+     * Used by ServiceTokenBootstrap to detect rotation scenarios — i.e. an
+     * existing service-kind row whose hash differs from the one we are
+     * about to insert.
+     */
+    public function countServiceTokens(): int
+    {
+        $value = $this->connection->fetchOne(
+            'SELECT COUNT(*) FROM api_tokens WHERE kind = :kind AND revoked_at IS NULL',
+            ['kind' => TokenKind::Service->value]
+        );
+
+        return (int) $value;
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    private function hydrate(array $row): TokenRecord
+    {
+        $kind = TokenKind::from((string) $row['kind']);
+        $role = isset($row['role']) && $row['role'] !== null
+            ? Role::from((string) $row['role'])
+            : null;
+
+        return new TokenRecord(
+            id: (int) $row['id'],
+            kind: $kind,
+            hash: (string) $row['token_hash'],
+            prefix: (string) $row['token_prefix'],
+            reporterId: $row['reporter_id'] !== null ? (int) $row['reporter_id'] : null,
+            consumerId: $row['consumer_id'] !== null ? (int) $row['consumer_id'] : null,
+            role: $role,
+            expiresAt: self::parseDate($row['expires_at'] ?? null),
+            revokedAt: self::parseDate($row['revoked_at'] ?? null),
+            lastUsedAt: self::parseDate($row['last_used_at'] ?? null),
+        );
+    }
+
+    private static function parseDate(mixed $value): ?DateTimeImmutable
+    {
+        if ($value === null || $value === '') {
+            return null;
+        }
+
+        return new DateTimeImmutable((string) $value, new DateTimeZone('UTC'));
+    }
+}

+ 176 - 0
api/src/Infrastructure/Auth/UserRepository.php

@@ -0,0 +1,176 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Auth;
+
+use App\Domain\Auth\Role;
+use App\Domain\User\User;
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\DBAL\Connection;
+
+/**
+ * DBAL-backed gateway for the users table.
+ *
+ * Owns identity upsert flows for OIDC and local sign-in. The OIDC flow
+ * recomputes role from current group memberships on every login so role
+ * changes in Entra propagate without an admin needing to "re-link" users.
+ */
+final class UserRepository
+{
+    public function __construct(
+        private readonly Connection $connection,
+        private readonly RoleMappingRepository $roleMappings,
+    ) {
+    }
+
+    public function findById(int $id): ?User
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, subject, email, display_name, role, is_local FROM users WHERE id = :id',
+            ['id' => $id]
+        );
+
+        return $row === false ? null : $this->hydrate($row);
+    }
+
+    public function findBySubject(string $subject): ?User
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, subject, email, display_name, role, is_local FROM users WHERE subject = :subject',
+            ['subject' => $subject]
+        );
+
+        return $row === false ? null : $this->hydrate($row);
+    }
+
+    public function findLocalByUsername(string $username): ?User
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, subject, email, display_name, role, is_local FROM users '
+            . 'WHERE is_local = :local AND display_name = :name LIMIT 1',
+            ['local' => 1, 'name' => $username]
+        );
+
+        return $row === false ? null : $this->hydrate($row);
+    }
+
+    /**
+     * @param list<string> $groupIds
+     */
+    public function upsertOidc(
+        string $subject,
+        string $email,
+        string $displayName,
+        array $groupIds,
+        Role $defaultRole,
+    ): User {
+        $resolvedRole = $this->roleMappings->resolveRole($groupIds, $defaultRole);
+        $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
+
+        $existing = $this->findBySubject($subject);
+        if ($existing !== null) {
+            $this->connection->update(
+                'users',
+                [
+                    'email' => $email,
+                    'display_name' => $displayName,
+                    'role' => $resolvedRole->value,
+                    'last_login_at' => $now,
+                ],
+                ['id' => $existing->id]
+            );
+
+            return new User(
+                id: $existing->id,
+                subject: $subject,
+                email: $email,
+                displayName: $displayName,
+                role: $resolvedRole,
+                isLocal: false,
+            );
+        }
+
+        $this->connection->insert('users', [
+            'subject' => $subject,
+            'email' => $email,
+            'display_name' => $displayName,
+            'role' => $resolvedRole->value,
+            'is_local' => 0,
+            'last_login_at' => $now,
+        ]);
+
+        $id = (int) $this->connection->lastInsertId();
+
+        return new User(
+            id: $id,
+            subject: $subject,
+            email: $email,
+            displayName: $displayName,
+            role: $resolvedRole,
+            isLocal: false,
+        );
+    }
+
+    public function upsertLocal(string $username): User
+    {
+        $existing = $this->findLocalByUsername($username);
+        $now = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
+
+        if ($existing !== null) {
+            $this->connection->update(
+                'users',
+                ['last_login_at' => $now],
+                ['id' => $existing->id]
+            );
+
+            return new User(
+                id: $existing->id,
+                subject: null,
+                email: null,
+                displayName: $existing->displayName ?? $username,
+                role: Role::Admin,
+                isLocal: true,
+            );
+        }
+
+        $this->connection->insert('users', [
+            'subject' => null,
+            'email' => null,
+            'display_name' => $username,
+            'role' => Role::Admin->value,
+            'is_local' => 1,
+            'last_login_at' => $now,
+        ]);
+
+        $id = (int) $this->connection->lastInsertId();
+
+        return new User(
+            id: $id,
+            subject: null,
+            email: null,
+            displayName: $username,
+            role: Role::Admin,
+            isLocal: true,
+        );
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    private function hydrate(array $row): User
+    {
+        return new User(
+            id: (int) $row['id'],
+            subject: isset($row['subject']) && $row['subject'] !== null ? (string) $row['subject'] : null,
+            email: isset($row['email']) && $row['email'] !== null ? (string) $row['email'] : null,
+            displayName: isset($row['display_name']) && $row['display_name'] !== null ? (string) $row['display_name'] : null,
+            role: Role::from((string) $row['role']),
+            isLocal: (bool) $row['is_local'],
+        );
+    }
+}

+ 83 - 0
api/src/Infrastructure/Http/JsonErrorHandler.php

@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Http;
+
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Log\LoggerInterface;
+use Slim\Exception\HttpException;
+use Slim\Exception\HttpMethodNotAllowedException;
+use Slim\Exception\HttpNotFoundException;
+use Throwable;
+
+/**
+ * Slim-compatible error handler that produces uniform
+ * `{"error":"...","details":...}` JSON responses.
+ *
+ * Logs every uncaught exception at error level. In production we do not
+ * leak exception messages from non-HTTP exceptions to clients (returns a
+ * generic "internal error"); in development the logger has the full
+ * detail and the response body carries the message for debuggability.
+ *
+ * Wired in public/index.php as the default invokable.
+ */
+final class JsonErrorHandler
+{
+    public function __construct(
+        private readonly ResponseFactoryInterface $responseFactory,
+        private readonly LoggerInterface $logger,
+        private readonly bool $exposeDetails,
+    ) {
+    }
+
+    public function __invoke(
+        ServerRequestInterface $request,
+        Throwable $exception,
+        bool $displayErrorDetails,
+        bool $logErrors,
+        bool $logErrorDetails,
+    ): ResponseInterface {
+        if ($logErrors) {
+            $this->logger->error($exception->getMessage(), [
+                'exception' => $exception::class,
+                'method' => $request->getMethod(),
+                'path' => $request->getUri()->getPath(),
+                'trace' => $logErrorDetails ? $exception->getTraceAsString() : null,
+            ]);
+        }
+
+        [$status, $payload] = $this->shape($exception);
+        $expose = $displayErrorDetails || $this->exposeDetails;
+
+        if (!$expose && $status >= 500) {
+            $payload = ['error' => 'internal error'];
+        }
+
+        $response = $this->responseFactory->createResponse($status)
+            ->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode($payload));
+
+        return $response;
+    }
+
+    /**
+     * @return array{int, array<string, mixed>}
+     */
+    private function shape(Throwable $e): array
+    {
+        if ($e instanceof HttpNotFoundException) {
+            return [404, ['error' => 'not_found']];
+        }
+        if ($e instanceof HttpMethodNotAllowedException) {
+            return [405, ['error' => 'method_not_allowed']];
+        }
+        if ($e instanceof HttpException) {
+            return [$e->getCode(), ['error' => $e->getMessage()]];
+        }
+
+        return [500, ['error' => $e->getMessage() !== '' ? $e->getMessage() : 'internal error']];
+    }
+}

+ 87 - 0
api/src/Infrastructure/Http/Middleware/ImpersonationMiddleware.php

@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Http\Middleware;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Domain\Auth\TokenKind;
+use App\Infrastructure\Auth\UserRepository;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * For service tokens, requires `X-Acting-User-Id` and rewrites the
+ * principal to carry that user's id and role. Per SPEC §8 this header is
+ * trusted *only* alongside a service token; for any other kind it is
+ * silently ignored (no 400 — that would let an attacker probe header
+ * handling on non-service tokens).
+ *
+ * Failure modes:
+ *  - service token + missing header                → 400
+ *  - service token + malformed header              → 400
+ *  - service token + header points to unknown user → 403
+ */
+final class ImpersonationMiddleware implements MiddlewareInterface
+{
+    public const HEADER = 'X-Acting-User-Id';
+
+    public function __construct(
+        private readonly UserRepository $users,
+        private readonly ResponseFactoryInterface $responseFactory,
+    ) {
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
+        if (!$principal instanceof AuthenticatedPrincipal) {
+            // Auth middleware should have populated this. Defensive 401.
+            return $this->jsonError(401, 'unauthorized');
+        }
+
+        if ($principal->tokenKind !== TokenKind::Service) {
+            return $handler->handle($request);
+        }
+
+        $headerValue = trim($request->getHeaderLine(self::HEADER));
+        if ($headerValue === '') {
+            return $this->jsonError(400, 'missing X-Acting-User-Id');
+        }
+
+        if (preg_match('/^[1-9][0-9]*$/', $headerValue) !== 1) {
+            return $this->jsonError(400, 'invalid X-Acting-User-Id');
+        }
+
+        $userId = (int) $headerValue;
+        $user = $this->users->findById($userId);
+        if ($user === null) {
+            return $this->jsonError(403, 'unknown impersonated user');
+        }
+
+        $rewritten = new AuthenticatedPrincipal(
+            tokenKind: $principal->tokenKind,
+            userId: $user->id,
+            role: $user->role,
+            reporterId: null,
+            consumerId: null,
+            tokenId: $principal->tokenId,
+        );
+
+        return $handler->handle(
+            $request->withAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL, $rewritten)
+        );
+    }
+
+    private function jsonError(int $status, string $message): ResponseInterface
+    {
+        $response = $this->responseFactory->createResponse($status)
+            ->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode(['error' => $message]));
+
+        return $response;
+    }
+}

+ 72 - 0
api/src/Infrastructure/Http/Middleware/RbacMiddleware.php

@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Http\Middleware;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Route-attached RBAC gate. Use as `RbacMiddleware::require($container, Role::Operator)`
+ * — returns a middleware instance bound to that required role.
+ *
+ * Reporter and consumer tokens have no role and *must not* reach
+ * admin/auth routes; for them this middleware returns 401 ("unauthorized")
+ * — matching the SPEC rule that "wrong kind for the route" looks the same
+ * as a missing or revoked token. 403 is reserved for "authenticated, role
+ * is too low for this endpoint."
+ */
+final class RbacMiddleware implements MiddlewareInterface
+{
+    public function __construct(
+        private readonly Role $required,
+        private readonly ResponseFactoryInterface $responseFactory,
+    ) {
+    }
+
+    public static function require(ResponseFactoryInterface $factory, Role $required): self
+    {
+        return new self($required, $factory);
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
+        if (!$principal instanceof AuthenticatedPrincipal) {
+            return $this->jsonError(401, 'unauthorized');
+        }
+
+        // Reporter / consumer tokens never satisfy admin-route role checks.
+        if ($principal->tokenKind === TokenKind::Reporter || $principal->tokenKind === TokenKind::Consumer) {
+            return $this->jsonError(401, 'unauthorized');
+        }
+
+        if ($principal->role === null) {
+            // Service token without impersonation should already have been
+            // 400'd by ImpersonationMiddleware; defensive 403 here.
+            return $this->jsonError(403, 'forbidden');
+        }
+
+        if (!$principal->role->satisfies($this->required)) {
+            return $this->jsonError(403, 'forbidden');
+        }
+
+        return $handler->handle($request);
+    }
+
+    private function jsonError(int $status, string $message): ResponseInterface
+    {
+        $response = $this->responseFactory->createResponse($status)
+            ->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode(['error' => $message]));
+
+        return $response;
+    }
+}

+ 88 - 0
api/src/Infrastructure/Http/Middleware/TokenAuthenticationMiddleware.php

@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Http\Middleware;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Domain\Auth\Token;
+use App\Domain\Auth\TokenHasher;
+use App\Domain\Auth\TokenKind;
+use App\Infrastructure\Auth\TokenRepository;
+use DateTimeImmutable;
+use DateTimeZone;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Validates the `Authorization: Bearer …` header, attaches an
+ * AuthenticatedPrincipal to the request, and updates last_used_at.
+ *
+ * Returns a uniform 401 `{"error":"unauthorized"}` for any failure mode —
+ * missing header, malformed token, unknown hash, revoked, expired. The
+ * caller can never tell which one. ImpersonationMiddleware and
+ * RbacMiddleware run after this and supply 400/403 separately.
+ */
+final class TokenAuthenticationMiddleware implements MiddlewareInterface
+{
+    public const ATTR_PRINCIPAL = 'principal';
+    public const ATTR_TOKEN_RECORD = 'tokenRecord';
+
+    public function __construct(
+        private readonly TokenRepository $tokens,
+        private readonly TokenHasher $hasher,
+        private readonly ResponseFactoryInterface $responseFactory,
+    ) {
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $header = $request->getHeaderLine('Authorization');
+        if ($header === '' || stripos($header, 'Bearer ') !== 0) {
+            return $this->unauthorized();
+        }
+
+        $raw = trim(substr($header, 7));
+        $parsed = Token::parse($raw);
+        if ($parsed === null) {
+            return $this->unauthorized();
+        }
+
+        $hash = $this->hasher->hash($raw);
+        $record = $this->tokens->findByHash($hash);
+        if ($record === null) {
+            return $this->unauthorized();
+        }
+
+        // Update last_used_at synchronously. Move to write-behind in M14
+        // if perf demands.
+        $this->tokens->markUsed((int) $record->id, new DateTimeImmutable('now', new DateTimeZone('UTC')));
+
+        $principal = new AuthenticatedPrincipal(
+            tokenKind: $record->kind,
+            userId: null,
+            role: $record->kind === TokenKind::Admin ? $record->role : null,
+            reporterId: $record->reporterId,
+            consumerId: $record->consumerId,
+            tokenId: (int) $record->id,
+        );
+
+        $request = $request
+            ->withAttribute(self::ATTR_TOKEN_RECORD, $record)
+            ->withAttribute(self::ATTR_PRINCIPAL, $principal);
+
+        return $handler->handle($request);
+    }
+
+    private function unauthorized(): ResponseInterface
+    {
+        $response = $this->responseFactory->createResponse(401)
+            ->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode(['error' => 'unauthorized']));
+
+        return $response;
+    }
+}

+ 186 - 0
api/tests/Integration/Auth/AuthEndpointsTest.php

@@ -0,0 +1,186 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Auth;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * Behavioural tests for the /api/v1/auth/* endpoints. These verify the
+ * upsert flows and OIDC role resolution from group mappings.
+ */
+final class AuthEndpointsTest extends AppTestCase
+{
+    public function testUpsertLocalCreatesUserOnFirstCall(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $adminId = $this->createUser(Role::Admin, isLocal: true);
+
+        $response = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-local',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'X-Acting-User-Id' => (string) $adminId,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode(['username' => 'second']) ?: null
+        );
+        self::assertSame(200, $response->getStatusCode());
+
+        $body = $this->decode($response);
+        self::assertIsInt($body['user_id']);
+        self::assertSame('admin', $body['role']);
+        self::assertNull($body['email']);
+        self::assertSame('second', $body['display_name']);
+        self::assertTrue($body['is_local']);
+    }
+
+    public function testUpsertLocalIsIdempotent(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $adminId = $this->createUser(Role::Admin, isLocal: true);
+
+        $headers = [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $adminId,
+            'Content-Type' => 'application/json',
+        ];
+        $body = json_encode(['username' => 'idempotent']) ?: null;
+
+        $first = $this->decode($this->request('POST', '/api/v1/auth/users/upsert-local', $headers, $body));
+        $second = $this->decode($this->request('POST', '/api/v1/auth/users/upsert-local', $headers, $body));
+
+        self::assertSame($first['user_id'], $second['user_id']);
+    }
+
+    public function testUpsertOidcResolvesRoleFromGroups(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $adminId = $this->createUser(Role::Admin, isLocal: true);
+
+        // Seed a role mapping: group "ops-group" → operator
+        $this->db->insert('oidc_role_mappings', [
+            'group_id' => 'ops-group',
+            'role' => Role::Operator->value,
+        ]);
+        $this->db->insert('oidc_role_mappings', [
+            'group_id' => 'admin-group',
+            'role' => Role::Admin->value,
+        ]);
+
+        $response = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-oidc',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'X-Acting-User-Id' => (string) $adminId,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode([
+                'subject' => 'sub-1',
+                'email' => 'alice@example.com',
+                'display_name' => 'Alice',
+                'groups' => ['ops-group', 'admin-group'],
+            ]) ?: null
+        );
+        self::assertSame(200, $response->getStatusCode());
+
+        $body = $this->decode($response);
+        self::assertSame('admin', $body['role'], 'highest matching role wins');
+        self::assertSame('alice@example.com', $body['email']);
+        self::assertSame('Alice', $body['display_name']);
+        self::assertFalse($body['is_local']);
+    }
+
+    public function testUpsertOidcFallsBackToDefaultRoleWithNoMatchingGroup(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $adminId = $this->createUser(Role::Admin, isLocal: true);
+
+        $response = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-oidc',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'X-Acting-User-Id' => (string) $adminId,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode([
+                'subject' => 'sub-default',
+                'email' => 'b@example.com',
+                'display_name' => 'B',
+                'groups' => ['unknown-group'],
+            ]) ?: null
+        );
+        self::assertSame(200, $response->getStatusCode());
+
+        // Default in tests is Role::Viewer.
+        self::assertSame('viewer', $this->decode($response)['role']);
+    }
+
+    public function testUpsertOidcRecomputesRoleOnSubsequentLogins(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $adminId = $this->createUser(Role::Admin, isLocal: true);
+
+        $this->db->insert('oidc_role_mappings', [
+            'group_id' => 'g1',
+            'role' => Role::Operator->value,
+        ]);
+
+        $headers = [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $adminId,
+            'Content-Type' => 'application/json',
+        ];
+
+        $first = $this->decode($this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-oidc',
+            $headers,
+            json_encode([
+                'subject' => 'churn',
+                'email' => 'c@example.com',
+                'display_name' => 'C',
+                'groups' => ['g1'],
+            ]) ?: null
+        ));
+        self::assertSame('operator', $first['role']);
+
+        // Subsequent login with no matching group → role drops to default viewer.
+        $second = $this->decode($this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-oidc',
+            $headers,
+            json_encode([
+                'subject' => 'churn',
+                'email' => 'c@example.com',
+                'display_name' => 'C',
+                'groups' => [],
+            ]) ?: null
+        ));
+        self::assertSame($first['user_id'], $second['user_id']);
+        self::assertSame('viewer', $second['role']);
+    }
+
+    public function testUpsertOidcRejectsAdminToken(): void
+    {
+        // Even an admin token can't call /auth/* — those are service-only.
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $response = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-local',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            json_encode(['username' => 'admin']) ?: null
+        );
+        self::assertSame(403, $response->getStatusCode());
+    }
+}

+ 177 - 0
api/tests/Integration/Auth/AuthMatrixTest.php

@@ -0,0 +1,177 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Auth;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * Covers the auth matrix from M03 task §7. Each test corresponds to one
+ * row of the table:
+ *
+ *  | Token kind   | X-Acting-User-Id | User exists | Required | Expected |
+ *  | ------------ | ---------------- | ----------- | -------- | -------- |
+ *  | none         | -                | -           | viewer   | 401      |
+ *  | bad token    | -                | -           | viewer   | 401      |
+ *  | reporter     | -                | -           | viewer   | 401      |
+ *  | admin/viewer | -                | -           | viewer   | 200      |
+ *  | admin/viewer | -                | -           | operator | 403      |
+ *  | admin/admin  | -                | -           | admin    | 200      |
+ *  | service      | no               | -           | viewer   | 400      |
+ *  | service      | yes              | no          | viewer   | 403      |
+ *  | service      | yes (viewer)     | yes         | viewer   | 200      |
+ *  | service      | yes (viewer)     | yes         | operator | 403      |
+ *  | service      | yes (admin)      | yes         | admin    | 200      |
+ *
+ * /admin/me requires Viewer; /auth/users/upsert-local requires Admin.
+ * Together they exercise both required-role rungs.
+ */
+final class AuthMatrixTest extends AppTestCase
+{
+    public function testNoTokenReturns401(): void
+    {
+        $response = $this->request('GET', '/api/v1/admin/me');
+        self::assertSame(401, $response->getStatusCode());
+        self::assertSame('unauthorized', $this->decode($response)['error']);
+    }
+
+    public function testBadlyFormattedTokenReturns401(): void
+    {
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer not_a_real_token',
+        ]);
+        self::assertSame(401, $response->getStatusCode());
+    }
+
+    public function testWellFormedButUnknownTokenReturns401(): void
+    {
+        // Right format, never persisted → no DB hit on lookup.
+        $raw = 'irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $raw,
+        ]);
+        self::assertSame(401, $response->getStatusCode());
+    }
+
+    public function testReporterTokenOnAdminRouteReturns401(): void
+    {
+        $reporterId = $this->createReporter();
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(401, $response->getStatusCode());
+    }
+
+    public function testAdminTokenWithViewerRoleSatisfiesViewerRoute(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('viewer', $body['role']);
+        self::assertSame('admin-token', $body['source']);
+        self::assertNull($body['user_id']);
+    }
+
+    public function testAdminTokenWithAdminRoleSatisfiesAdminRoute(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        // /admin/me requires Viewer; an Admin role satisfies it.
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+        self::assertSame('admin', $this->decode($response)['role']);
+    }
+
+    public function testServiceTokenWithoutImpersonationHeaderReturns400(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(400, $response->getStatusCode());
+        self::assertSame('missing X-Acting-User-Id', $this->decode($response)['error']);
+    }
+
+    public function testServiceTokenWithUnknownUserReturns403(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => '99999',
+        ]);
+        self::assertSame(403, $response->getStatusCode());
+    }
+
+    public function testServiceTokenWithMalformedImpersonationHeaderReturns400(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => 'not-an-int',
+        ]);
+        self::assertSame(400, $response->getStatusCode());
+    }
+
+    public function testServiceTokenImpersonatingViewerReachesViewerRoute(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $userId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-viewer');
+
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $userId,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+
+        $body = $this->decode($response);
+        self::assertSame($userId, $body['user_id']);
+        self::assertSame('viewer', $body['role']);
+        self::assertSame('oidc', $body['source']);
+        self::assertFalse($body['is_local']);
+    }
+
+    public function testServiceTokenImpersonatingAdminReachesAdminRoute(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $userId = $this->createUser(Role::Admin, isLocal: true);
+
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $userId,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+
+        $body = $this->decode($response);
+        self::assertSame('admin', $body['role']);
+        self::assertTrue($body['is_local']);
+        self::assertSame('local', $body['source']);
+    }
+
+    public function testActingUserHeaderIgnoredForAdminToken(): void
+    {
+        // Per SPEC §8: X-Acting-User-Id is *only* trusted with the service token.
+        // For an admin token it must not be 400'd, just ignored.
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => '99999',
+        ]);
+
+        self::assertSame(200, $response->getStatusCode());
+        self::assertSame('admin-token', $this->decode($response)['source']);
+    }
+}

+ 93 - 0
api/tests/Integration/Auth/ServiceTokenBootstrapTest.php

@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Auth;
+
+use App\Domain\Auth\TokenIssuer;
+use App\Domain\Auth\TokenKind;
+use App\Infrastructure\Auth\ServiceTokenBootstrap;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class ServiceTokenBootstrapTest extends AppTestCase
+{
+    public function testBootstrapInsertsServiceTokenRow(): void
+    {
+        /** @var ServiceTokenBootstrap $boot */
+        $boot = $this->container->get(ServiceTokenBootstrap::class);
+        /** @var TokenIssuer $issuer */
+        $issuer = $this->container->get(TokenIssuer::class);
+
+        $raw = $issuer->issue(TokenKind::Service);
+        $boot->bootstrap($raw);
+
+        $count = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'"
+        );
+        self::assertSame(1, $count);
+    }
+
+    public function testBootstrapIsIdempotent(): void
+    {
+        /** @var ServiceTokenBootstrap $boot */
+        $boot = $this->container->get(ServiceTokenBootstrap::class);
+        /** @var TokenIssuer $issuer */
+        $issuer = $this->container->get(TokenIssuer::class);
+
+        $raw = $issuer->issue(TokenKind::Service);
+        $boot->bootstrap($raw);
+        $boot->bootstrap($raw);
+        $boot->bootstrap($raw);
+
+        self::assertSame(
+            1,
+            (int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
+        );
+    }
+
+    public function testBootstrapWithEmptyTokenIsNoOp(): void
+    {
+        /** @var ServiceTokenBootstrap $boot */
+        $boot = $this->container->get(ServiceTokenBootstrap::class);
+
+        $boot->bootstrap('');
+
+        self::assertSame(
+            0,
+            (int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
+        );
+    }
+
+    public function testBootstrapWithMalformedTokenIsNoOp(): void
+    {
+        /** @var ServiceTokenBootstrap $boot */
+        $boot = $this->container->get(ServiceTokenBootstrap::class);
+
+        $boot->bootstrap('not-a-token');
+
+        self::assertSame(
+            0,
+            (int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
+        );
+    }
+
+    public function testBootstrapWithDifferentTokenInsertsNewRow(): void
+    {
+        /** @var ServiceTokenBootstrap $boot */
+        $boot = $this->container->get(ServiceTokenBootstrap::class);
+        /** @var TokenIssuer $issuer */
+        $issuer = $this->container->get(TokenIssuer::class);
+
+        $first = $issuer->issue(TokenKind::Service);
+        $second = $issuer->issue(TokenKind::Service);
+
+        $boot->bootstrap($first);
+        $boot->bootstrap($second);
+
+        // Both rows should now exist; operator must revoke the old one manually.
+        self::assertSame(
+            2,
+            (int) $this->db->fetchOne("SELECT COUNT(*) FROM api_tokens WHERE kind = 'service'")
+        );
+    }
+}

+ 205 - 0
api/tests/Integration/Support/AppTestCase.php

@@ -0,0 +1,205 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Support;
+
+use App\App\AppFactory;
+use App\App\Container;
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenHasher;
+use App\Domain\Auth\TokenIssuer;
+use App\Domain\Auth\TokenKind;
+use App\Infrastructure\Auth\TokenRecord;
+use App\Infrastructure\Auth\TokenRepository;
+use Doctrine\DBAL\Connection;
+use Monolog\Handler\NullHandler;
+use Monolog\Logger;
+use Phinx\Config\Config;
+use Phinx\Migration\Manager;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Slim\App;
+use Slim\Psr7\Factory\ServerRequestFactory;
+use Slim\Psr7\Factory\StreamFactory;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\NullOutput;
+
+/**
+ * Boots a fresh on-disk SQLite database, runs every migration + seeder
+ * against it, and exposes helpers for hitting the Slim app from tests.
+ *
+ * On-disk (not :memory:) because Phinx runs in a separate connection from
+ * our DBAL Connection — :memory: would give us two empty databases.
+ *
+ * Each test gets its own DB, container, and Slim app so state never leaks.
+ */
+abstract class AppTestCase extends TestCase
+{
+    protected ContainerInterface $container;
+    protected App $app;
+    protected Connection $db;
+    protected string $sqlitePath;
+
+    protected function setUp(): void
+    {
+        $this->sqlitePath = sys_get_temp_dir() . '/irdb-auth-' . bin2hex(random_bytes(6)) . '.sqlite';
+        touch($this->sqlitePath);
+
+        $config = new Config([
+            'paths' => [
+                'migrations' => __DIR__ . '/../../../db/migrations',
+                'seeds' => __DIR__ . '/../../../db/seeds',
+            ],
+            'environments' => [
+                'default_migration_table' => 'phinxlog',
+                'default_environment' => 'test',
+                'test' => [
+                    'adapter' => 'sqlite',
+                    'name' => $this->sqlitePath,
+                    'suffix' => '',
+                ],
+            ],
+            'version_order' => 'creation',
+        ]);
+
+        $manager = new Manager($config, new ArrayInput([]), new NullOutput());
+        $manager->migrate('test');
+        $manager->seed('test');
+
+        $settings = [
+            'app_env' => 'development',
+            'log_level' => \Monolog\Level::Warning,
+            'app_secret' => 'test',
+            'db' => [
+                'driver' => 'sqlite',
+                'sqlite_path' => $this->sqlitePath,
+                'mysql_host' => '',
+                'mysql_port' => 3306,
+                'mysql_database' => '',
+                'mysql_username' => '',
+                'mysql_password' => '',
+            ],
+            'ui_service_token' => '',
+            'internal_job_token' => '',
+            'ui_origin' => 'http://localhost:8080',
+            'oidc_default_role' => Role::Viewer,
+        ];
+
+        $this->container = Container::build($settings);
+        // Replace the logger with a null sink so integration tests don't spam
+        // stdout (PHPUnit treats unexpected output as a "risky" outcome).
+        if (method_exists($this->container, 'set')) {
+            $nullLogger = new Logger('test');
+            $nullLogger->pushHandler(new NullHandler());
+            /** @var \DI\Container $container */
+            $container = $this->container;
+            $container->set(LoggerInterface::class, $nullLogger);
+        }
+        /** @var Connection $conn */
+        $conn = $this->container->get(Connection::class);
+        $this->db = $conn;
+
+        $this->app = AppFactory::build($this->container);
+    }
+
+    protected function tearDown(): void
+    {
+        $this->db->close();
+        if (file_exists($this->sqlitePath)) {
+            @unlink($this->sqlitePath);
+        }
+    }
+
+    /**
+     * Issues a token of the given kind, persists it, and returns the raw
+     * string. The caller can then send it as `Authorization: Bearer …`.
+     */
+    protected function createToken(
+        TokenKind $kind,
+        ?Role $role = null,
+        ?int $reporterId = null,
+        ?int $consumerId = null,
+    ): string {
+        /** @var TokenIssuer $issuer */
+        $issuer = $this->container->get(TokenIssuer::class);
+        /** @var TokenHasher $hasher */
+        $hasher = $this->container->get(TokenHasher::class);
+        /** @var TokenRepository $repo */
+        $repo = $this->container->get(TokenRepository::class);
+
+        $raw = $issuer->issue($kind);
+        $repo->create(new TokenRecord(
+            id: null,
+            kind: $kind,
+            hash: $hasher->hash($raw),
+            prefix: substr($raw, 0, 8),
+            reporterId: $reporterId,
+            consumerId: $consumerId,
+            role: $role,
+            expiresAt: null,
+            revokedAt: null,
+            lastUsedAt: null,
+        ));
+
+        return $raw;
+    }
+
+    /**
+     * Inserts a row in `users` with the given role and returns the id.
+     */
+    protected function createUser(Role $role, bool $isLocal = false, ?string $subject = null): int
+    {
+        $this->db->insert('users', [
+            'subject' => $subject,
+            'email' => $isLocal ? null : 'user@example.com',
+            'display_name' => $isLocal ? 'admin' : 'OIDC User',
+            'role' => $role->value,
+            'is_local' => $isLocal ? 1 : 0,
+        ]);
+
+        return (int) $this->db->lastInsertId();
+    }
+
+    protected function createReporter(string $name = 'rep-test'): int
+    {
+        $this->db->insert('reporters', [
+            'name' => $name,
+            'trust_weight' => '1.00',
+            'is_active' => 1,
+        ]);
+
+        return (int) $this->db->lastInsertId();
+    }
+
+    /**
+     * @param array<string, string> $headers
+     */
+    protected function request(string $method, string $path, array $headers = [], ?string $body = null): \Psr\Http\Message\ResponseInterface
+    {
+        $reqFactory = new ServerRequestFactory();
+        $request = $reqFactory->createServerRequest($method, $path);
+        foreach ($headers as $name => $value) {
+            $request = $request->withHeader($name, $value);
+        }
+        if ($body !== null) {
+            $stream = (new StreamFactory())->createStream($body);
+            $request = $request->withBody($stream);
+        }
+
+        return $this->app->handle($request);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    protected function decode(\Psr\Http\Message\ResponseInterface $response): array
+    {
+        $raw = (string) $response->getBody();
+        $decoded = json_decode($raw, true);
+        self::assertIsArray($decoded, 'response body was not JSON: ' . $raw);
+
+        return $decoded;
+    }
+}

+ 125 - 0
api/tests/Unit/Auth/RbacMiddlewareTest.php

@@ -0,0 +1,125 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Auth;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Infrastructure\Http\Middleware\RbacMiddleware;
+use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
+use PHPUnit\Framework\TestCase;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Slim\Psr7\Factory\ResponseFactory;
+use Slim\Psr7\Factory\ServerRequestFactory;
+use Slim\Psr7\Response;
+
+/**
+ * Direct unit coverage for the RBAC tier table from M03 §7. Integration
+ * tests in AuthMatrixTest exercise the routes that exist; these target
+ * the operator/admin rungs which have no live route in M03.
+ */
+final class RbacMiddlewareTest extends TestCase
+{
+    public function testAdminPrincipalSatisfiesAdminRoute(): void
+    {
+        self::assertSame(200, $this->dispatch(Role::Admin, $this->principal(Role::Admin)));
+    }
+
+    public function testOperatorPrincipalSatisfiesOperatorRoute(): void
+    {
+        self::assertSame(200, $this->dispatch(Role::Operator, $this->principal(Role::Operator)));
+    }
+
+    public function testViewerPrincipalForbiddenFromOperatorRoute(): void
+    {
+        self::assertSame(403, $this->dispatch(Role::Operator, $this->principal(Role::Viewer)));
+    }
+
+    public function testOperatorPrincipalForbiddenFromAdminRoute(): void
+    {
+        self::assertSame(403, $this->dispatch(Role::Admin, $this->principal(Role::Operator)));
+    }
+
+    public function testReporterPrincipalUnauthorizedOnAnyAdminRoute(): void
+    {
+        $principal = new AuthenticatedPrincipal(
+            tokenKind: TokenKind::Reporter,
+            userId: null,
+            role: null,
+            reporterId: 1,
+            consumerId: null,
+            tokenId: 1,
+        );
+
+        self::assertSame(401, $this->dispatch(Role::Viewer, $principal));
+    }
+
+    public function testServicePrincipalWithoutRoleForbidden(): void
+    {
+        // ImpersonationMiddleware would normally have populated role; if it
+        // didn't (e.g. someone mounted an admin route without it), we expect
+        // a defensive 403 rather than a misleading 200.
+        $principal = new AuthenticatedPrincipal(
+            tokenKind: TokenKind::Service,
+            userId: null,
+            role: null,
+            reporterId: null,
+            consumerId: null,
+            tokenId: 1,
+        );
+
+        self::assertSame(403, $this->dispatch(Role::Viewer, $principal));
+    }
+
+    public function testMissingPrincipalReturns401(): void
+    {
+        $factory = new ResponseFactory();
+        $middleware = RbacMiddleware::require($factory, Role::Viewer);
+
+        $request = (new ServerRequestFactory())->createServerRequest('GET', '/x');
+        $handler = new class () implements RequestHandlerInterface {
+            public function handle(ServerRequestInterface $request): ResponseInterface
+            {
+                return new Response();
+            }
+        };
+
+        $response = $middleware->process($request, $handler);
+        self::assertSame(401, $response->getStatusCode());
+    }
+
+    private function principal(Role $role): AuthenticatedPrincipal
+    {
+        return new AuthenticatedPrincipal(
+            tokenKind: TokenKind::Admin,
+            userId: 42,
+            role: $role,
+            reporterId: null,
+            consumerId: null,
+            tokenId: 7,
+        );
+    }
+
+    private function dispatch(Role $required, AuthenticatedPrincipal $principal): int
+    {
+        $factory = new ResponseFactory();
+        $middleware = RbacMiddleware::require($factory, $required);
+
+        $request = (new ServerRequestFactory())
+            ->createServerRequest('GET', '/x')
+            ->withAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL, $principal);
+
+        $handler = new class () implements RequestHandlerInterface {
+            public function handle(ServerRequestInterface $request): ResponseInterface
+            {
+                return new Response();
+            }
+        };
+
+        return $middleware->process($request, $handler)->getStatusCode();
+    }
+}

+ 32 - 0
api/tests/Unit/Auth/RoleTest.php

@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Auth;
+
+use App\Domain\Auth\Role;
+use PHPUnit\Framework\TestCase;
+
+final class RoleTest extends TestCase
+{
+    public function testAdminSatisfiesEverything(): void
+    {
+        self::assertTrue(Role::Admin->satisfies(Role::Viewer));
+        self::assertTrue(Role::Admin->satisfies(Role::Operator));
+        self::assertTrue(Role::Admin->satisfies(Role::Admin));
+    }
+
+    public function testOperatorSatisfiesViewerButNotAdmin(): void
+    {
+        self::assertTrue(Role::Operator->satisfies(Role::Viewer));
+        self::assertTrue(Role::Operator->satisfies(Role::Operator));
+        self::assertFalse(Role::Operator->satisfies(Role::Admin));
+    }
+
+    public function testViewerOnlySatisfiesViewer(): void
+    {
+        self::assertTrue(Role::Viewer->satisfies(Role::Viewer));
+        self::assertFalse(Role::Viewer->satisfies(Role::Operator));
+        self::assertFalse(Role::Viewer->satisfies(Role::Admin));
+    }
+}

+ 29 - 0
api/tests/Unit/Auth/TokenHasherTest.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Auth;
+
+use App\Domain\Auth\TokenHasher;
+use PHPUnit\Framework\TestCase;
+
+final class TokenHasherTest extends TestCase
+{
+    public function testProducesSha256HexDigest(): void
+    {
+        $hasher = new TokenHasher();
+        $raw = 'irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+        $expected = hash('sha256', $raw);
+
+        self::assertSame($expected, $hasher->hash($raw));
+        self::assertSame(64, strlen($hasher->hash($raw)));
+    }
+
+    public function testIsDeterministic(): void
+    {
+        $hasher = new TokenHasher();
+        $raw = 'irdb_svc_ZYXWVUTSRQPONMLKJIHGFEDCBA765432';
+
+        self::assertSame($hasher->hash($raw), $hasher->hash($raw));
+    }
+}

+ 46 - 0
api/tests/Unit/Auth/TokenIssuerTest.php

@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Auth;
+
+use App\Domain\Auth\Token;
+use App\Domain\Auth\TokenIssuer;
+use App\Domain\Auth\TokenKind;
+use PHPUnit\Framework\TestCase;
+
+final class TokenIssuerTest extends TestCase
+{
+    public function testIssuedTokenMatchesFormat(): void
+    {
+        $issuer = new TokenIssuer();
+
+        foreach (TokenKind::cases() as $kind) {
+            $raw = $issuer->issue($kind);
+            self::assertSame(1, preg_match('/^irdb_(rep|con|adm|svc)_[A-Z2-7]{32}$/', $raw), "format mismatch for {$kind->value}: {$raw}");
+            self::assertStringStartsWith('irdb_' . $kind->code() . '_', $raw);
+        }
+    }
+
+    public function testIssuedTokensAreUnique(): void
+    {
+        $issuer = new TokenIssuer();
+        $set = [];
+        for ($i = 0; $i < 50; ++$i) {
+            $set[$issuer->issue(TokenKind::Admin)] = true;
+        }
+        self::assertCount(50, $set);
+    }
+
+    public function testIssuedTokenRoundTripsThroughParse(): void
+    {
+        $issuer = new TokenIssuer();
+        foreach (TokenKind::cases() as $kind) {
+            $raw = $issuer->issue($kind);
+            $parsed = Token::parse($raw);
+            self::assertNotNull($parsed, "parse failed for {$raw}");
+            self::assertSame($kind, $parsed->kind);
+            self::assertSame($raw, $parsed->raw);
+        }
+    }
+}

+ 56 - 0
api/tests/Unit/Auth/TokenTest.php

@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Auth;
+
+use App\Domain\Auth\Token;
+use App\Domain\Auth\TokenKind;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+final class TokenTest extends TestCase
+{
+    #[DataProvider('validTokens')]
+    public function testParsesValidTokens(string $raw, TokenKind $expectedKind): void
+    {
+        $token = Token::parse($raw);
+        self::assertNotNull($token);
+        self::assertSame($expectedKind, $token->kind);
+        self::assertSame(substr($raw, 0, 8), $token->prefix());
+    }
+
+    /**
+     * @return iterable<string, array{string, TokenKind}>
+     */
+    public static function validTokens(): iterable
+    {
+        // 32 chars from base32 alphabet
+        $body = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+        yield 'reporter' => ['irdb_rep_' . $body, TokenKind::Reporter];
+        yield 'consumer' => ['irdb_con_' . $body, TokenKind::Consumer];
+        yield 'admin' => ['irdb_adm_' . $body, TokenKind::Admin];
+        yield 'service' => ['irdb_svc_' . $body, TokenKind::Service];
+    }
+
+    #[DataProvider('invalidTokens')]
+    public function testRejectsInvalidTokens(string $raw): void
+    {
+        self::assertNull(Token::parse($raw));
+    }
+
+    /**
+     * @return iterable<string, array{string}>
+     */
+    public static function invalidTokens(): iterable
+    {
+        yield 'empty' => [''];
+        yield 'no prefix' => ['foo_bar_baz'];
+        yield 'wrong tag' => ['irdb_xyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'];
+        yield 'too short body' => ['irdb_adm_ABCDEFGH'];
+        yield 'lowercase body' => ['irdb_adm_abcdefghijklmnopqrstuvwxyz234567'];
+        yield 'invalid base32 char' => ['irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234560'];
+        yield 'too long body' => ['irdb_adm_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCD'];
+        yield 'missing underscore' => ['irdbadmABCDEFGHIJKLMNOPQRSTUVWXYZ234567'];
+    }
+}