Explorar o código

fix: gate impersonation on user active-status, add actor_via audit signal (SEC_REVIEW F11)

Three layers close the F11 gap that left service-token impersonation
unconstrained: any extant user id was accepted, no `disabled` column
existed on `users`, and audit rows did not distinguish OIDC-user
actions from local-admin actions.

A — active-status gate. Migration adds nullable `disabled_at` to
`users`; `User::isDisabled()` exposes the predicate;
`ImpersonationMiddleware` returns `403 user_disabled` for disabled
targets (mirroring the unknown-user 403 so the response shape does
not distinguish "missing" from "disabled"). `AuthController::upsert
{Oidc,Local}` short-circuit before role recompute on disabled rows,
so a disabled user cannot drift their role via group membership while
disabled.

B — `actor_via` audit column. Migration adds
`actor_via` (`oidc|local|admin-token|service|reporter|consumer|system`).
`ImpersonationMiddleware` threads the user's `is_local` flag onto
`AuthenticatedPrincipal`; `AuditContextMiddleware` derives `actor_via`
from it. Filterable on `/admin/audit-log?actor_via=local` so an auditor
can scope a review to local-admin actions without joining `users`.

C — admin user-CRUD. New `UsersController` (api) exposes
`GET /api/v1/admin/users`, `GET /{id}`, `PATCH /{id}` (`{disabled: bool}`).
PATCH wraps state change + `user.disabled`/`user.enabled` audit emit
in `Connection::transactional()` per F4. Refused with 409 on self-
disable (`cannot_disable_self`) and on disabling the local-admin row
(`cannot_disable_local_admin`) — the local admin is the documented
break-glass path; operators wanting to lock it unset
`LOCAL_ADMIN_PASSWORD_HASH` in the UI's env. New `/app/users` admin
page (sidebar entry, admin-only). OIDC controller routes upstream
`403 user_disabled` to `/no-access`; local login surfaces a generic
"invalid credentials" flash to keep the path probe-resistant.

Regression tests: `api/tests/Integration/Auth/DisabledUserTest.php`
covers impersonation 403 on disabled rows, upsertOidc rejection
without role recompute or audit drift, upsertLocal rejection on
disabled local admin, `actor_via` derivation for impersonated-local
vs OIDC vs admin-token paths, admin disable + audit emit + idempotency,
self-disable / local-admin-disable 409 guards, and the
`/audit-log?actor_via=local` filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa hai 5 días
pai
achega
f2dd3fddee

+ 47 - 0
api/db/migrations/20260504110000_add_disabled_at_to_users.php

@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * SEC_REVIEW F11: add a `disabled_at` nullable timestamp to `users` so an
+ * operator can revoke a user without deleting the audit-history row.
+ *
+ * `NULL` means active; any non-null value means the user is disabled (the
+ * stamp is informational — only the predicate `IS NULL` matters at the
+ * impersonation boundary). Indexed because both the impersonation
+ * 403-check (`SELECT … WHERE id = ?`) and the admin-users list page filter
+ * by it; the index is partial on SQLite to keep the live-user lookup
+ * branch index-free.
+ */
+final class AddDisabledAtToUsers extends BaseMigration
+{
+    public function up(): void
+    {
+        $table = $this->table('users');
+        $this->addTimestampColumn($table, 'disabled_at', ['null' => true, 'after' => 'is_local']);
+        $table->update();
+
+        // Partial index speeds up the "list disabled users" admin page; the
+        // active-user lookup path doesn't need it (PK lookup already).
+        if ($this->isMysql()) {
+            $this->execute('CREATE INDEX idx_users_disabled_at ON users (disabled_at)');
+        } else {
+            $this->execute('CREATE INDEX idx_users_disabled_at ON users (disabled_at) WHERE disabled_at IS NOT NULL');
+        }
+    }
+
+    public function down(): void
+    {
+        if ($this->isMysql()) {
+            $this->execute('DROP INDEX idx_users_disabled_at ON users');
+        } else {
+            $this->execute('DROP INDEX IF EXISTS idx_users_disabled_at');
+        }
+
+        $this->table('users')
+            ->removeColumn('disabled_at')
+            ->update();
+    }
+}

+ 52 - 0
api/db/migrations/20260504110001_add_actor_via_to_audit_log.php

@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Infrastructure\Db\Migrations\BaseMigration;
+
+/**
+ * SEC_REVIEW F11: every audit row gets an `actor_via` discriminator
+ * recording how the actor reached the API.
+ *
+ * Values:
+ *   - `oidc`         — service token + impersonation, user.is_local = 0
+ *   - `local`        — service token + impersonation, user.is_local = 1
+ *   - `admin-token`  — bearer of an admin-kind token
+ *   - `service`      — service token without impersonation (auth/upsert)
+ *   - `reporter`     — reporter-kind token (public report endpoint)
+ *   - `consumer`     — consumer-kind token (public blocklist)
+ *   - `system`       — no principal (jobs, system-internal emits)
+ *
+ * The existing `actor_kind` column already encodes most of this, but
+ * conflates "user via OIDC browser" and "user via local-admin form": both
+ * surface as `actor_kind=user`. With `actor_via` populated, an auditor
+ * filtering on `actor_via='local'` can scope reviews to a specific
+ * sign-in path without joining `users` on every row.
+ *
+ * Backfill: NULL on existing rows. The column is queryable but optional;
+ * the API surfaces it on read but does not require it on filter.
+ */
+final class AddActorViaToAuditLog extends BaseMigration
+{
+    public function up(): void
+    {
+        $this->table('audit_log')
+            ->addColumn('actor_via', 'string', ['limit' => 16, 'null' => true, 'after' => 'actor_id'])
+            ->update();
+
+        $this->execute('CREATE INDEX idx_audit_actor_via ON audit_log (actor_via)');
+    }
+
+    public function down(): void
+    {
+        if ($this->isMysql()) {
+            $this->execute('DROP INDEX idx_audit_actor_via ON audit_log');
+        } else {
+            $this->execute('DROP INDEX IF EXISTS idx_audit_actor_via');
+        }
+
+        $this->table('audit_log')
+            ->removeColumn('actor_via')
+            ->update();
+    }
+}

+ 176 - 0
api/public/openapi.yaml

@@ -956,6 +956,117 @@ paths:
       responses:
         '200':
           description: Preview
+  '/api/v1/admin/users':
+    get:
+      tags:
+        - Admin
+      summary: List users (Admin)
+      description: |
+        Roster of every IRDB user (OIDC + the single local-admin row).
+        Used by the admin **users** page to disable / re-enable accounts
+        (SEC_REVIEW F11).
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - '$ref': '#/components/parameters/Page'
+        - '$ref': '#/components/parameters/PageSize'
+      responses:
+        '200':
+          description: User page
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  page:
+                    type: integer
+                  page_size:
+                    type: integer
+                  total:
+                    type: integer
+                  items:
+                    type: array
+                    items:
+                      '$ref': '#/components/schemas/UserRecord'
+  '/api/v1/admin/users/{id}':
+    get:
+      tags:
+        - Admin
+      summary: Get user (Admin)
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: User record
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/UserRecord'
+        '404':
+          description: Not found
+    patch:
+      tags:
+        - Admin
+      summary: Disable / enable user (Admin)
+      description: |
+        SEC_REVIEW F11. Toggles `disabled_at` on the user row. A disabled
+        user cannot complete OIDC or local sign-in and cannot be
+        impersonated via the service token (impersonation 403s with
+        `user_disabled`).
+
+        Refused with 409 when:
+          - the target id matches the acting user (`cannot_disable_self`),
+          - the target is the local-admin row (`cannot_disable_local_admin`).
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              required: [disabled]
+              properties:
+                disabled:
+                  type: boolean
+      responses:
+        '200':
+          description: Updated record
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/UserRecord'
+        '400':
+          description: Validation error
+        '404':
+          description: Not found
+        '409':
+          description: Refused (self-disable or local-admin)
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  error:
+                    type: string
+                    enum:
+                      - cannot_disable_self
+                      - cannot_disable_local_admin
   '/api/v1/admin/audit-log':
     get:
       tags:
@@ -979,6 +1090,23 @@ paths:
           in: query
           schema:
             type: integer
+        - name: actor_via
+          in: query
+          description: |
+            SEC_REVIEW F11. Filter rows by upstream auth path. `oidc` and
+            `local` further split rows that already have `actor_kind=user`
+            (impersonation), so an auditor can scope a review to local-admin
+            actions vs OIDC-user actions without joining the users table.
+          schema:
+            type: string
+            enum:
+              - oidc
+              - local
+              - admin-token
+              - service
+              - reporter
+              - consumer
+              - system
         - name: action
           in: query
           schema:
@@ -1708,6 +1836,22 @@ components:
         actor_id:
           type: string
           nullable: true
+        actor_via:
+          type: string
+          nullable: true
+          description: |
+            SEC_REVIEW F11. Upstream auth path used by the actor. `oidc` /
+            `local` are subdivisions of `actor_kind=user`; the others mirror
+            their `actor_kind`. NULL on rows written before the column was
+            added.
+          enum:
+            - oidc
+            - local
+            - admin-token
+            - service
+            - reporter
+            - consumer
+            - system
         action:
           type: string
           example: manual_block.created
@@ -1818,6 +1962,38 @@ components:
             - admin-token
         is_local:
           type: boolean
+    UserRecord:
+      type: object
+      description: |
+        Admin-API representation of a `users` row. Shared by
+        `/api/v1/admin/users` list/get/patch.
+      properties:
+        id:
+          type: integer
+        subject:
+          type: string
+          nullable: true
+          description: OIDC `sub` claim (null for the local-admin row).
+        email:
+          type: string
+          nullable: true
+        display_name:
+          type: string
+          nullable: true
+        role:
+          type: string
+          enum:
+            - viewer
+            - operator
+            - admin
+        is_local:
+          type: boolean
+        disabled:
+          type: boolean
+        disabled_at:
+          type: string
+          format: date-time
+          nullable: true
     Pagination:
       type: object
       properties:

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

@@ -19,6 +19,7 @@ use App\Application\Admin\PoliciesController;
 use App\Application\Admin\ReportersController;
 use App\Application\Admin\StatsController;
 use App\Application\Admin\TokensController;
+use App\Application\Admin\UsersController;
 use App\Application\Auth\AuthController;
 use App\Application\Internal\JobsController;
 use App\Application\Public\BlocklistController;
@@ -279,6 +280,15 @@ final class AppFactory
             $admin->delete('/categories/{id}', [$categories, 'delete'])
                 ->add(RbacMiddleware::require($rf, Role::Admin));
 
+            // Users: Admin only — list + show + PATCH disable/enable.
+            /** @var UsersController $users */
+            $users = $container->get(UsersController::class);
+            $admin->group('/users', function (RouteCollectorProxy $r) use ($users): void {
+                $r->get('', [$users, 'list']);
+                $r->get('/{id}', [$users, 'show']);
+                $r->patch('/{id}', [$users, 'update']);
+            })->add(RbacMiddleware::require($rf, Role::Admin));
+
             // Audit log: Viewer.
             /** @var AuditController $audit */
             $audit = $container->get(AuditController::class);

+ 2 - 0
api/src/App/Container.php

@@ -19,6 +19,7 @@ use App\Application\Admin\PoliciesController;
 use App\Application\Admin\ReportersController;
 use App\Application\Admin\StatsController;
 use App\Application\Admin\TokensController;
+use App\Application\Admin\UsersController;
 use App\Application\Auth\AuthController;
 use App\Application\Internal\JobsController;
 use App\Application\Jobs\CleanupAuditJob;
@@ -436,6 +437,7 @@ final class Container
             IpsController::class => autowire(),
             StatsController::class => autowire(),
             CategoriesController::class => autowire(),
+            UsersController::class => autowire(),
             AuditController::class => autowire(),
             JobsAdminController::class => autowire(),
             MaintenanceController::class => autowire(),

+ 9 - 0
api/src/Application/Admin/AuditController.php

@@ -21,6 +21,7 @@ final class AuditController
     use AdminControllerSupport;
 
     private const ALLOWED_ACTOR_KINDS = ['user', 'admin-token', 'reporter', 'consumer', 'system'];
+    private const ALLOWED_ACTOR_VIA = ['oidc', 'local', 'admin-token', 'service', 'reporter', 'consumer', 'system'];
 
     public function __construct(private readonly AuditRepository $audit)
     {
@@ -55,6 +56,14 @@ final class AuditController
             }
         }
 
+        if (isset($params['actor_via']) && is_string($params['actor_via']) && $params['actor_via'] !== '') {
+            if (!in_array($params['actor_via'], self::ALLOWED_ACTOR_VIA, true)) {
+                $errors['actor_via'] = 'must be one of: ' . implode(', ', self::ALLOWED_ACTOR_VIA);
+            } else {
+                $filters['actor_via'] = $params['actor_via'];
+            }
+        }
+
         if (isset($params['action']) && is_string($params['action']) && $params['action'] !== '') {
             $filters['action'] = $params['action'];
         }

+ 194 - 0
api/src/Application/Admin/UsersController.php

@@ -0,0 +1,194 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Audit\AuditAction;
+use App\Domain\Audit\AuditEmitter;
+use App\Domain\Time\Clock;
+use App\Domain\User\User;
+use App\Infrastructure\Auth\UserRepository;
+use Doctrine\DBAL\Connection;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * `/api/v1/admin/users` — admin-only user roster + disable/enable.
+ *
+ * Read surface: paginated list of all `users`. Write surface: a single
+ * `PATCH /{id}` that toggles `disabled_at` (SEC_REVIEW F11). Disabling a
+ * user immediately blocks impersonation: ImpersonationMiddleware refuses
+ * to rewrite the principal, and `AuthController::upsert{Oidc,Local}`
+ * refuses to recompute role on the disabled row, so a subsequent OIDC or
+ * local sign-in attempt 403s.
+ *
+ * Two guard rails on disable:
+ *   - Cannot disable yourself (`self_disable`) — the operator who would
+ *     have to re-enable the row would be locked out alongside.
+ *   - Cannot disable the local-admin row (`cannot_disable_local_admin`) —
+ *     the local admin is the documented break-glass path. Per SPEC §6,
+ *     there's exactly one such row; locking it requires DB access to
+ *     recover. If an operator really wants to disable local sign-in,
+ *     they unset `LOCAL_ADMIN_PASSWORD_HASH` in the UI's env instead.
+ *
+ * Both writes are wrapped in `Connection::transactional()` + emitOrThrow
+ * per SEC_REVIEW F4 — a failed audit insert rolls back the state change.
+ */
+final class UsersController
+{
+    use AdminControllerSupport;
+
+    public function __construct(
+        private readonly UserRepository $users,
+        private readonly AuditEmitter $audit,
+        private readonly Connection $connection,
+        private readonly Clock $clock,
+    ) {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $page = self::pagination($request, defaultLimit: 50, maxLimit: 200);
+        $items = $this->users->listAll($page['limit'], $page['offset']);
+        $total = $this->users->countAll();
+
+        return self::json($response, 200, [
+            'items' => array_map(self::serialize(...), $items),
+            'page' => $page['page'],
+            'page_size' => $page['limit'],
+            'total' => $total,
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $user = $this->users->findById($id);
+        if ($user === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        return self::json($response, 200, self::serialize($user));
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $existing = $this->users->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $body = self::jsonBody($request);
+        if (!array_key_exists('disabled', $body) || !is_bool($body['disabled'])) {
+            return self::validationFailed($response, ['disabled' => 'required, boolean']);
+        }
+        $targetDisabled = $body['disabled'];
+
+        // Idempotent: state already matches → 200 with the row, no audit emit.
+        if ($targetDisabled === $existing->isDisabled()) {
+            return self::json($response, 200, self::serialize($existing));
+        }
+
+        if ($targetDisabled) {
+            $actingUserId = self::actingUserId($request);
+            if ($actingUserId === $id) {
+                return self::json($response, 409, ['error' => 'cannot_disable_self']);
+            }
+            if ($existing->isLocal) {
+                return self::json($response, 409, ['error' => 'cannot_disable_local_admin']);
+            }
+        }
+
+        $auditCtx = self::auditContext($request);
+        $this->connection->transactional(function () use ($id, $targetDisabled, $existing, $auditCtx): void {
+            if ($targetDisabled) {
+                $this->users->disable($id, $this->clock->now());
+                $this->audit->emitOrThrow(
+                    AuditAction::USER_DISABLED,
+                    'user',
+                    $id,
+                    [
+                        'subject' => $existing->subject,
+                        'email' => $existing->email,
+                        'display_name' => $existing->displayName,
+                        'role' => $existing->role->value,
+                        'is_local' => $existing->isLocal,
+                    ],
+                    $auditCtx,
+                    $existing->email ?? $existing->displayName,
+                );
+            } else {
+                $this->users->enable($id);
+                $this->audit->emitOrThrow(
+                    AuditAction::USER_ENABLED,
+                    'user',
+                    $id,
+                    [
+                        'subject' => $existing->subject,
+                        'email' => $existing->email,
+                        'display_name' => $existing->displayName,
+                        'role' => $existing->role->value,
+                        'is_local' => $existing->isLocal,
+                        'previously_disabled_at' => $existing->disabledAt?->format('c'),
+                    ],
+                    $auditCtx,
+                    $existing->email ?? $existing->displayName,
+                );
+            }
+        });
+
+        $updated = $this->users->findById($id);
+        if ($updated === null) {
+            return self::error($response, 500, 'update_failed');
+        }
+
+        return self::json($response, 200, self::serialize($updated));
+    }
+
+    private static function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+
+    /**
+     * @return array{
+     *   id: int,
+     *   subject: ?string,
+     *   email: ?string,
+     *   display_name: ?string,
+     *   role: string,
+     *   is_local: bool,
+     *   disabled: bool,
+     *   disabled_at: ?string,
+     * }
+     */
+    private static function serialize(User $user): array
+    {
+        return [
+            'id' => $user->id,
+            'subject' => $user->subject,
+            'email' => $user->email,
+            'display_name' => $user->displayName,
+            'role' => $user->role->value,
+            'is_local' => $user->isLocal,
+            'disabled' => $user->isDisabled(),
+            'disabled_at' => $user->disabledAt?->format('c'),
+        ];
+    }
+}

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

@@ -49,6 +49,14 @@ final class AuthController
             return self::error($response, 400, 'invalid request body');
         }
 
+        // SEC_REVIEW F11: a disabled user must not be able to log in.
+        // Check before opening the transaction so we don't recompute role
+        // (which would emit `user.role_changed` for a denied login).
+        $existingPre = $this->users->findBySubject($subject);
+        if ($existingPre !== null && $existingPre->isDisabled()) {
+            return self::error($response, 403, 'user_disabled');
+        }
+
         $auditCtx = self::auditContext($request);
 
         $user = $this->connection->transactional(function () use ($subject, $email, $displayName, $groups, $auditCtx) {
@@ -115,6 +123,14 @@ final class AuthController
             return self::error($response, 400, 'invalid request body');
         }
 
+        // SEC_REVIEW F11: refuse if the local-admin row exists but has been
+        // disabled. Operator must re-enable via /admin/users before the
+        // local sign-in path works again.
+        $existingPre = $this->users->findLocal();
+        if ($existingPre !== null && $existingPre->isDisabled()) {
+            return self::error($response, 403, 'user_disabled');
+        }
+
         $auditCtx = self::auditContext($request);
 
         $user = $this->connection->transactional(function () use ($username, $auditCtx) {

+ 2 - 0
api/src/Domain/Audit/AuditAction.php

@@ -45,6 +45,8 @@ final class AuditAction
 
     public const USER_CREATED = 'user.created';
     public const USER_ROLE_CHANGED = 'user.role_changed';
+    public const USER_DISABLED = 'user.disabled';
+    public const USER_ENABLED = 'user.enabled';
 
     public const OIDC_ROLE_MAPPING_CREATED = 'oidc_role_mapping.created';
     public const OIDC_ROLE_MAPPING_DELETED = 'oidc_role_mapping.deleted';

+ 22 - 7
api/src/Domain/Audit/AuditContext.php

@@ -5,14 +5,20 @@ declare(strict_types=1);
 namespace App\Domain\Audit;
 
 /**
- * Per-request audit context — actor identity and source IP.
+ * Per-request audit context — actor identity, source IP, and the
+ * upstream-auth discriminator (`actorVia`).
  *
  * Resolved by AuditContextMiddleware from the active principal:
- * - service token + impersonation -> actorKind=user, actorId=user_id
- *   (NOT the service token; per SPEC §8 the user is the responsible party)
- * - admin token -> actorKind=admin-token, actorId=token_id
- * - reporter / consumer tokens -> actorKind=reporter / consumer
- * - no principal at all (system-internal) -> actorKind=system, no actorId
+ * - service token + impersonation → actorKind=user, actorId=user_id
+ *   (NOT the service token; per SPEC §8 the user is the responsible
+ *   party). actorVia is `oidc` or `local` based on the user's `is_local`
+ *   flag (SEC_REVIEW F11), so an auditor can filter rows by sign-in
+ *   path without joining the users table.
+ * - admin token → actorKind=admin-token, actorId=token_id, via=admin-token
+ * - reporter / consumer tokens → actorKind=reporter / consumer, via mirrors
+ * - service token without impersonation → actorKind=system, via=service
+ *   (only the auth/upsert endpoints; bootstraps users without an actor)
+ * - no principal at all (system-internal) → actorKind=system, via=system
  */
 final class AuditContext
 {
@@ -22,17 +28,26 @@ final class AuditContext
     public const KIND_CONSUMER = 'consumer';
     public const KIND_SYSTEM = 'system';
 
+    public const VIA_OIDC = 'oidc';
+    public const VIA_LOCAL = 'local';
+    public const VIA_ADMIN_TOKEN = 'admin-token';
+    public const VIA_SERVICE = 'service';
+    public const VIA_REPORTER = 'reporter';
+    public const VIA_CONSUMER = 'consumer';
+    public const VIA_SYSTEM = 'system';
+
     public function __construct(
         public readonly string $actorKind,
         public readonly ?int $actorId,
         public readonly ?string $actorName,
         public readonly ?string $sourceIp,
         public readonly ?string $requestId,
+        public readonly ?string $actorVia = null,
     ) {
     }
 
     public static function system(): self
     {
-        return new self(self::KIND_SYSTEM, null, null, null, null);
+        return new self(self::KIND_SYSTEM, null, null, null, null, self::VIA_SYSTEM);
     }
 }

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

@@ -18,6 +18,11 @@ namespace App\Domain\Auth;
  *
  * `role` is set for `admin` tokens (the token's own role) and after
  * service-impersonation (the impersonated user's role).
+ *
+ * `userIsLocal` is set whenever `userId` is — its value is the matching
+ * user record's `is_local` flag. AuditContextMiddleware reads it to
+ * classify `actor_via` as `oidc` vs `local` for impersonated rows
+ * (SEC_REVIEW F11).
  */
 final class AuthenticatedPrincipal
 {
@@ -28,6 +33,7 @@ final class AuthenticatedPrincipal
         public readonly ?int $reporterId,
         public readonly ?int $consumerId,
         public readonly int $tokenId,
+        public readonly ?bool $userIsLocal = null,
     ) {
     }
 }

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

@@ -5,11 +5,17 @@ declare(strict_types=1);
 namespace App\Domain\User;
 
 use App\Domain\Auth\Role;
+use DateTimeImmutable;
 
 /**
  * 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`.
+ *
+ * `disabledAt` is null while the user is active. Any non-null value flips
+ * the row off: ImpersonationMiddleware refuses to rewrite the principal,
+ * and `AuthController::upsertOidc` / `upsertLocal` refuse to recompute
+ * role on disabled rows. SEC_REVIEW F11.
  */
 final class User
 {
@@ -20,6 +26,12 @@ final class User
         public readonly ?string $displayName,
         public readonly Role $role,
         public readonly bool $isLocal,
+        public readonly ?DateTimeImmutable $disabledAt = null,
     ) {
     }
+
+    public function isDisabled(): bool
+    {
+        return $this->disabledAt !== null;
+    }
 }

+ 9 - 2
api/src/Infrastructure/Audit/AuditRepository.php

@@ -36,6 +36,7 @@ class AuditRepository extends RepositoryBase
         $id = $this->insertRow('audit_log', [
             'actor_kind' => $context->actorKind,
             'actor_id' => $context->actorId !== null ? (string) $context->actorId : null,
+            'actor_via' => $context->actorVia,
             'action' => $action,
             'target_type' => $entityType,
             'target_id' => $entityId !== null ? (string) $entityId : null,
@@ -52,6 +53,7 @@ class AuditRepository extends RepositoryBase
      * @param array{
      *     actor_kind?: ?string,
      *     actor_id?: ?int,
+     *     actor_via?: ?string,
      *     action?: ?string,
      *     entity_type?: ?string,
      *     entity_id?: ?string,
@@ -61,7 +63,7 @@ class AuditRepository extends RepositoryBase
      *     to?: ?string,
      * } $filters
      * @return array{
-     *     items: list<array{id: int, occurred_at: string, actor_kind: string, actor_id: ?string, action: string, entity_type: ?string, entity_id: ?string, entity_label: ?string, details: array<string, mixed>|null, source_ip: ?string}>,
+     *     items: list<array{id: int, occurred_at: string, actor_kind: string, actor_id: ?string, actor_via: ?string, action: string, entity_type: ?string, entity_id: ?string, entity_label: ?string, details: array<string, mixed>|null, source_ip: ?string}>,
      *     total: int,
      * }
      */
@@ -78,6 +80,10 @@ class AuditRepository extends RepositoryBase
             $where[] = 'actor_id = :actor_id';
             $params['actor_id'] = (string) $filters['actor_id'];
         }
+        if (!empty($filters['actor_via'])) {
+            $where[] = 'actor_via = :actor_via';
+            $params['actor_via'] = $filters['actor_via'];
+        }
         if (!empty($filters['action'])) {
             $where[] = 'action = :action';
             $params['action'] = $filters['action'];
@@ -117,7 +123,7 @@ class AuditRepository extends RepositoryBase
         $itemTypes = ['limit' => ParameterType::INTEGER, 'offset' => ParameterType::INTEGER];
 
         $rows = $this->connection()->fetchAllAssociative(
-            'SELECT id, actor_kind, actor_id, action, target_type, target_id, target_label, details_json, ip_address, created_at '
+            'SELECT id, actor_kind, actor_id, actor_via, action, target_type, target_id, target_label, details_json, ip_address, created_at '
             . 'FROM audit_log' . $whereSql . ' ORDER BY id DESC LIMIT :limit OFFSET :offset',
             $itemsParams,
             $itemTypes,
@@ -143,6 +149,7 @@ class AuditRepository extends RepositoryBase
                 'occurred_at' => self::isoTimestamp((string) $row['created_at']),
                 'actor_kind' => (string) $row['actor_kind'],
                 'actor_id' => $row['actor_id'] !== null ? (string) $row['actor_id'] : null,
+                'actor_via' => $row['actor_via'] !== null ? (string) $row['actor_via'] : null,
                 'action' => (string) $row['action'],
                 'entity_type' => $row['target_type'] !== null ? (string) $row['target_type'] : null,
                 'entity_id' => $row['target_id'] !== null ? (string) $row['target_id'] : null,

+ 52 - 3
api/src/Infrastructure/Auth/UserRepository.php

@@ -19,6 +19,8 @@ use Doctrine\DBAL\Connection;
  */
 final class UserRepository
 {
+    private const COLUMNS = 'id, subject, email, display_name, role, is_local, disabled_at, last_login_at, created_at';
+
     public function __construct(
         private readonly Connection $connection,
         private readonly RoleMappingRepository $roleMappings,
@@ -29,7 +31,7 @@ final class UserRepository
     {
         /** @var array<string, mixed>|false $row */
         $row = $this->connection->fetchAssociative(
-            'SELECT id, subject, email, display_name, role, is_local FROM users WHERE id = :id',
+            'SELECT ' . self::COLUMNS . ' FROM users WHERE id = :id',
             ['id' => $id]
         );
 
@@ -40,7 +42,7 @@ final class UserRepository
     {
         /** @var array<string, mixed>|false $row */
         $row = $this->connection->fetchAssociative(
-            'SELECT id, subject, email, display_name, role, is_local FROM users WHERE subject = :subject',
+            'SELECT ' . self::COLUMNS . ' FROM users WHERE subject = :subject',
             ['subject' => $subject]
         );
 
@@ -63,7 +65,7 @@ final class UserRepository
     {
         /** @var array<string, mixed>|false $row */
         $row = $this->connection->fetchAssociative(
-            'SELECT id, subject, email, display_name, role, is_local FROM users '
+            'SELECT ' . self::COLUMNS . ' FROM users '
             . 'WHERE is_local = :local ORDER BY id ASC LIMIT 1',
             ['local' => 1]
         );
@@ -71,6 +73,45 @@ final class UserRepository
         return $row === false ? null : $this->hydrate($row);
     }
 
+    /**
+     * Admin "users" page: list all users with newest first. Pagination is
+     * intentionally simple (limit + offset) since the user count is bounded
+     * by the IdP — typically tens, not millions.
+     *
+     * @return list<User>
+     */
+    public function listAll(int $limit, int $offset): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection->fetchAllAssociative(
+            'SELECT ' . self::COLUMNS . ' FROM users '
+            . 'ORDER BY id DESC LIMIT :limit OFFSET :offset',
+            ['limit' => $limit, 'offset' => $offset],
+            ['limit' => \Doctrine\DBAL\ParameterType::INTEGER, 'offset' => \Doctrine\DBAL\ParameterType::INTEGER],
+        );
+
+        return array_map(fn (array $row) => $this->hydrate($row), $rows);
+    }
+
+    public function countAll(): int
+    {
+        return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM users');
+    }
+
+    public function disable(int $id, DateTimeImmutable $when): void
+    {
+        $this->connection->update(
+            'users',
+            ['disabled_at' => $when->format('Y-m-d H:i:s')],
+            ['id' => $id],
+        );
+    }
+
+    public function enable(int $id): void
+    {
+        $this->connection->update('users', ['disabled_at' => null], ['id' => $id]);
+    }
+
     /**
      * @param list<string> $groupIds
      */
@@ -104,6 +145,7 @@ final class UserRepository
                 displayName: $displayName,
                 role: $resolvedRole,
                 isLocal: false,
+                disabledAt: $existing->disabledAt,
             );
         }
 
@@ -156,6 +198,7 @@ final class UserRepository
                 displayName: $username,
                 role: Role::Admin,
                 isLocal: true,
+                disabledAt: $existing->disabledAt,
             );
         }
 
@@ -185,6 +228,11 @@ final class UserRepository
      */
     private function hydrate(array $row): User
     {
+        $disabledAt = null;
+        if (isset($row['disabled_at']) && $row['disabled_at'] !== null && $row['disabled_at'] !== '') {
+            $disabledAt = new DateTimeImmutable((string) $row['disabled_at'], new DateTimeZone('UTC'));
+        }
+
         return new User(
             id: (int) $row['id'],
             subject: isset($row['subject']) && $row['subject'] !== null ? (string) $row['subject'] : null,
@@ -192,6 +240,7 @@ final class UserRepository
             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'],
+            disabledAt: $disabledAt,
         );
     }
 }

+ 33 - 3
api/src/Infrastructure/Http/Middleware/AuditContextMiddleware.php

@@ -19,6 +19,12 @@ use Psr\Http\Server\RequestHandlerInterface;
  * service-token + impersonation has already produced `userId` and we
  * can honour the SPEC §8 invariant: the impersonated user is the
  * audit actor, never the service token.
+ *
+ * SEC_REVIEW F11: also derives `actor_via` (`oidc` / `local` /
+ * `admin-token` / `service` / `reporter` / `consumer` / `system`) for
+ * the audit row. ImpersonationMiddleware threads the user's `is_local`
+ * flag through to the principal so we can split impersonated rows by
+ * sign-in path without re-querying `users`.
  */
 final class AuditContextMiddleware implements MiddlewareInterface
 {
@@ -36,12 +42,32 @@ final class AuditContextMiddleware implements MiddlewareInterface
     private static function contextFor(mixed $principal, ?string $sourceIp, ?string $requestId): AuditContext
     {
         if (!$principal instanceof AuthenticatedPrincipal) {
-            return new AuditContext(AuditContext::KIND_SYSTEM, null, null, $sourceIp, $requestId);
+            return new AuditContext(
+                AuditContext::KIND_SYSTEM,
+                null,
+                null,
+                $sourceIp,
+                $requestId,
+                AuditContext::VIA_SYSTEM,
+            );
         }
 
         // Service token + impersonation: attribute to the impersonated user.
+        // `actor_via` distinguishes the upstream sign-in path so an auditor
+        // can filter local-admin actions separately from OIDC-user actions.
         if ($principal->tokenKind === TokenKind::Service && $principal->userId !== null) {
-            return new AuditContext(AuditContext::KIND_USER, $principal->userId, null, $sourceIp, $requestId);
+            $via = $principal->userIsLocal === true
+                ? AuditContext::VIA_LOCAL
+                : AuditContext::VIA_OIDC;
+
+            return new AuditContext(
+                AuditContext::KIND_USER,
+                $principal->userId,
+                null,
+                $sourceIp,
+                $requestId,
+                $via,
+            );
         }
 
         return match ($principal->tokenKind) {
@@ -51,6 +77,7 @@ final class AuditContextMiddleware implements MiddlewareInterface
                 null,
                 $sourceIp,
                 $requestId,
+                AuditContext::VIA_ADMIN_TOKEN,
             ),
             TokenKind::Reporter => new AuditContext(
                 AuditContext::KIND_REPORTER,
@@ -58,6 +85,7 @@ final class AuditContextMiddleware implements MiddlewareInterface
                 null,
                 $sourceIp,
                 $requestId,
+                AuditContext::VIA_REPORTER,
             ),
             TokenKind::Consumer => new AuditContext(
                 AuditContext::KIND_CONSUMER,
@@ -65,14 +93,16 @@ final class AuditContextMiddleware implements MiddlewareInterface
                 null,
                 $sourceIp,
                 $requestId,
+                AuditContext::VIA_CONSUMER,
             ),
-            // Service token without impersonation (rare — auth endpoints only).
+            // Service token without impersonation (auth endpoints only).
             TokenKind::Service => new AuditContext(
                 AuditContext::KIND_SYSTEM,
                 null,
                 null,
                 $sourceIp,
                 $requestId,
+                AuditContext::VIA_SERVICE,
             ),
         };
     }

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

@@ -24,6 +24,7 @@ use Psr\Http\Server\RequestHandlerInterface;
  *  - service token + missing header                → 400
  *  - service token + malformed header              → 400
  *  - service token + header points to unknown user → 403
+ *  - service token + header points to disabled user → 403 (SEC_REVIEW F11)
  */
 final class ImpersonationMiddleware implements MiddlewareInterface
 {
@@ -62,6 +63,14 @@ final class ImpersonationMiddleware implements MiddlewareInterface
             return $this->jsonError(403, 'unknown impersonated user');
         }
 
+        // SEC_REVIEW F11: a disabled user is unimpersonatable until the
+        // operator re-enables them. The 403 mirrors "unknown user" so an
+        // attacker probing user ids cannot use the response to distinguish
+        // "doesn't exist" from "disabled".
+        if ($user->isDisabled()) {
+            return $this->jsonError(403, 'user_disabled');
+        }
+
         $rewritten = new AuthenticatedPrincipal(
             tokenKind: $principal->tokenKind,
             userId: $user->id,
@@ -69,6 +78,7 @@ final class ImpersonationMiddleware implements MiddlewareInterface
             reporterId: null,
             consumerId: null,
             tokenId: $principal->tokenId,
+            userIsLocal: $user->isLocal,
         );
 
         return $handler->handle(

+ 386 - 0
api/tests/Integration/Auth/DisabledUserTest.php

@@ -0,0 +1,386 @@
+<?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;
+
+/**
+ * SEC_REVIEW F11: a disabled `users` row is unimpersonatable, refuses
+ * OIDC re-login (no role recompute, no audit drift), and refuses local
+ * sign-in. The admin user-CRUD endpoint is the only path that toggles
+ * `disabled_at` — it audits both directions and refuses self-disable
+ * and local-admin-disable.
+ */
+final class DisabledUserTest extends AppTestCase
+{
+    public function testServiceTokenImpersonatingDisabledUserReturns403(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $userId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-disabled', disabled: true);
+
+        $response = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $userId,
+        ]);
+
+        self::assertSame(403, $response->getStatusCode());
+        self::assertSame('user_disabled', $this->decode($response)['error']);
+    }
+
+    public function testEnabledUserOfSameKindStillWorksWhenAnotherIsDisabled(): void
+    {
+        // Sanity: disabling user A must not break impersonation for user B.
+        $token = $this->createToken(TokenKind::Service);
+        $disabledId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-A', disabled: true);
+        $activeId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-B', disabled: false);
+
+        $disabled = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $disabledId,
+        ]);
+        self::assertSame(403, $disabled->getStatusCode());
+
+        $active = $this->request('GET', '/api/v1/admin/me', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $activeId,
+        ]);
+        self::assertSame(200, $active->getStatusCode());
+    }
+
+    public function testUpsertOidcRefusesDisabledUserAndDoesNotRecomputeRole(): void
+    {
+        // A returning OIDC subject whose row was disabled must 403,
+        // and the role on the (still disabled) row must remain
+        // whatever it was — we must NOT have applied the new groups.
+        $token = $this->createToken(TokenKind::Service);
+        $this->db->insert('users', [
+            'subject' => 'churn-disabled',
+            'email' => 'old@example.com',
+            'display_name' => 'Churned',
+            'role' => Role::Viewer->value,
+            'is_local' => 0,
+            'disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
+        ]);
+
+        $this->db->insert('oidc_role_mappings', [
+            'group_id' => 'admin-grp',
+            'role' => Role::Admin->value,
+        ]);
+
+        $response = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-oidc',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode([
+                'subject' => 'churn-disabled',
+                'email' => 'new@example.com',
+                'display_name' => 'Renamed',
+                'groups' => ['admin-grp'],
+            ])
+        );
+
+        self::assertSame(403, $response->getStatusCode());
+        self::assertSame('user_disabled', $this->decode($response)['error']);
+
+        // Role must not have been recomputed.
+        $row = $this->db->fetchAssociative(
+            "SELECT role, email, display_name FROM users WHERE subject = 'churn-disabled'"
+        );
+        self::assertIsArray($row);
+        self::assertSame('viewer', $row['role'], 'disabled user role must not be recomputed from new groups');
+        self::assertSame('old@example.com', $row['email']);
+        self::assertSame('Churned', $row['display_name']);
+
+        // No user.role_changed audit row from this denied attempt.
+        $count = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM audit_log WHERE action = 'user.role_changed'"
+        );
+        self::assertSame(0, $count, 'denied login must not emit role_changed audit');
+    }
+
+    public function testUpsertLocalRefusesWhenLocalAdminDisabled(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        // Seed the local admin row pre-disabled.
+        $this->createUser(Role::Admin, isLocal: true, disabled: true);
+
+        $response = $this->request(
+            'POST',
+            '/api/v1/auth/users/upsert-local',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['username' => 'admin'])
+        );
+
+        self::assertSame(403, $response->getStatusCode());
+        self::assertSame('user_disabled', $this->decode($response)['error']);
+    }
+
+    public function testActorViaIsLocalForServiceImpersonationOfLocalAdmin(): void
+    {
+        // Layer B2: any audit emitted while impersonating a local user
+        // carries actor_via='local'; an OIDC user yields 'oidc'.
+        $token = $this->createToken(TokenKind::Service);
+        $localAdmin = $this->createUser(Role::Admin, isLocal: true);
+        $oidcAdmin = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-via-oidc');
+
+        // Drive an audit-emitting write under the local admin.
+        $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'X-Acting-User-Id' => (string) $localAdmin,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.1', 'reason' => 'via=local'])
+        );
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_kind, actor_id, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame('user', $row['actor_kind']);
+        self::assertSame((string) $localAdmin, $row['actor_id']);
+        self::assertSame('local', $row['actor_via']);
+
+        // Same write under the OIDC admin → actor_via='oidc'.
+        $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'X-Acting-User-Id' => (string) $oidcAdmin,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.2', 'reason' => 'via=oidc'])
+        );
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame('oidc', $row['actor_via']);
+    }
+
+    public function testActorViaIsAdminTokenForBareAdminToken(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $this->request(
+            'POST',
+            '/api/v1/admin/manual-blocks',
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.3', 'reason' => 'via=admin-token'])
+        );
+
+        $row = $this->db->fetchAssociative(
+            "SELECT actor_kind, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame('admin-token', $row['actor_kind']);
+        self::assertSame('admin-token', $row['actor_via']);
+    }
+
+    public function testAdminUsersListIncludesDisabledFlag(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $active = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-active');
+        $disabled = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-off', disabled: true);
+
+        $response = $this->request('GET', '/api/v1/admin/users', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $response->getStatusCode());
+
+        $body = $this->decode($response);
+        $byId = [];
+        foreach ($body['items'] as $item) {
+            $byId[(int) $item['id']] = $item;
+        }
+        self::assertArrayHasKey($active, $byId);
+        self::assertArrayHasKey($disabled, $byId);
+        self::assertFalse($byId[$active]['disabled']);
+        self::assertNull($byId[$active]['disabled_at']);
+        self::assertTrue($byId[$disabled]['disabled']);
+        self::assertNotNull($byId[$disabled]['disabled_at']);
+    }
+
+    public function testAdminPatchDisablesUserAndEmitsAudit(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'patch-target');
+
+        $response = $this->request(
+            'PATCH',
+            '/api/v1/admin/users/' . $target,
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['disabled' => true])
+        );
+        self::assertSame(200, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertTrue($body['disabled']);
+        self::assertNotNull($body['disabled_at']);
+
+        $row = $this->db->fetchAssociative(
+            "SELECT action, target_id, actor_kind FROM audit_log WHERE action = 'user.disabled' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($row);
+        self::assertSame((string) $target, $row['target_id']);
+
+        // Reverse — flipping back emits user.enabled.
+        $back = $this->request(
+            'PATCH',
+            '/api/v1/admin/users/' . $target,
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['disabled' => false])
+        );
+        self::assertSame(200, $back->getStatusCode());
+        self::assertFalse($this->decode($back)['disabled']);
+
+        $enabledRow = $this->db->fetchAssociative(
+            "SELECT action FROM audit_log WHERE action = 'user.enabled' ORDER BY id DESC LIMIT 1"
+        );
+        self::assertIsArray($enabledRow);
+    }
+
+    public function testAdminPatchRefusesSelfDisable(): void
+    {
+        $token = $this->createToken(TokenKind::Service);
+        $self = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-self');
+
+        $response = $this->request(
+            'PATCH',
+            '/api/v1/admin/users/' . $self,
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'X-Acting-User-Id' => (string) $self,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['disabled' => true])
+        );
+        self::assertSame(409, $response->getStatusCode());
+        self::assertSame('cannot_disable_self', $this->decode($response)['error']);
+
+        $row = $this->db->fetchAssociative(
+            'SELECT disabled_at FROM users WHERE id = :id',
+            ['id' => $self]
+        );
+        self::assertIsArray($row);
+        self::assertNull($row['disabled_at']);
+    }
+
+    public function testAdminPatchRefusesDisablingLocalAdmin(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $local = $this->createUser(Role::Admin, isLocal: true);
+
+        $response = $this->request(
+            'PATCH',
+            '/api/v1/admin/users/' . $local,
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['disabled' => true])
+        );
+        self::assertSame(409, $response->getStatusCode());
+        self::assertSame('cannot_disable_local_admin', $this->decode($response)['error']);
+    }
+
+    public function testAdminPatchIsIdempotentWithNoAuditOnNoOp(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-noop');
+
+        // Already enabled → patch enabled=false (no, we want already-active, set enabled): set disabled=false (no-op).
+        $beforeAuditCount = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')"
+        );
+
+        $response = $this->request(
+            'PATCH',
+            '/api/v1/admin/users/' . $target,
+            [
+                'Authorization' => 'Bearer ' . $token,
+                'Content-Type' => 'application/json',
+            ],
+            (string) json_encode(['disabled' => false])
+        );
+        self::assertSame(200, $response->getStatusCode());
+
+        $afterAuditCount = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')"
+        );
+        self::assertSame($beforeAuditCount, $afterAuditCount, 'no-op patch must not emit audit');
+    }
+
+    public function testAdminUsersRequiresAdminRole(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Viewer);
+
+        $response = $this->request('GET', '/api/v1/admin/users', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(403, $response->getStatusCode());
+    }
+
+    public function testAuditLogActorViaFilter(): void
+    {
+        // Filter on actor_via='local' returns only impersonated-local rows.
+        $token = $this->createToken(TokenKind::Service);
+        $local = $this->createUser(Role::Admin, isLocal: true);
+        $oidc = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-filter');
+
+        $localToken = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        // Three writes: two impersonated (one local, one oidc) + one admin token.
+        $this->request('POST', '/api/v1/admin/manual-blocks', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $local,
+            'Content-Type' => 'application/json',
+        ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.1', 'reason' => 'l']));
+        $this->request('POST', '/api/v1/admin/manual-blocks', [
+            'Authorization' => 'Bearer ' . $token,
+            'X-Acting-User-Id' => (string) $oidc,
+            'Content-Type' => 'application/json',
+        ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.2', 'reason' => 'o']));
+        $this->request('POST', '/api/v1/admin/manual-blocks', [
+            'Authorization' => 'Bearer ' . $localToken,
+            'Content-Type' => 'application/json',
+        ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.3', 'reason' => 'a']));
+
+        // Admin-list filtered to actor_via=local.
+        $resp = $this->request(
+            'GET',
+            '/api/v1/admin/audit-log?actor_via=local',
+            ['Authorization' => 'Bearer ' . $localToken],
+        );
+        self::assertSame(200, $resp->getStatusCode());
+
+        $body = $this->decode($resp);
+        $vias = array_map(static fn (array $row) => $row['actor_via'], $body['items']);
+        self::assertNotEmpty($vias);
+        foreach ($vias as $via) {
+            self::assertSame('local', $via);
+        }
+    }
+}

+ 10 - 2
api/tests/Integration/Support/AppTestCase.php

@@ -161,15 +161,23 @@ abstract class AppTestCase extends TestCase
 
     /**
      * Inserts a row in `users` with the given role and returns the id.
+     * Pass `disabled: true` to seed a disabled row (SEC_REVIEW F11 tests).
      */
-    protected function createUser(Role $role, bool $isLocal = false, ?string $subject = null): int
-    {
+    protected function createUser(
+        Role $role,
+        bool $isLocal = false,
+        ?string $subject = null,
+        bool $disabled = false,
+    ): 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,
+            'disabled_at' => $disabled
+                ? (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s')
+                : null,
         ]);
 
         return (int) $this->db->lastInsertId();

+ 74 - 0
ui/resources/views/pages/users/index.twig

@@ -0,0 +1,74 @@
+{% extends 'layout.twig' %}
+
+{% block title %}Users — IRDB{% endblock %}
+
+{% block content %}
+<div class="mx-auto max-w-5xl">
+    <div class="flex items-center justify-between">
+        <h1 class="text-2xl font-semibold tracking-tight">Users</h1>
+        <span class="text-sm text-slate-500 dark:text-slate-400">{{ list.total|default(0) }} total</span>
+    </div>
+    <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
+        Disabled users are blocked at the impersonation boundary — neither OIDC nor local sign-in completes for them until re-enabled.
+    </p>
+
+    <section class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
+        <table class="w-full text-sm">
+            <thead class="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400">
+                <tr>
+                    <th class="px-4 py-2 font-medium">ID</th>
+                    <th class="px-4 py-2 font-medium">Display name</th>
+                    <th class="px-4 py-2 font-medium">Email</th>
+                    <th class="px-4 py-2 font-medium">Role</th>
+                    <th class="px-4 py-2 font-medium">Source</th>
+                    <th class="px-4 py-2 font-medium">Status</th>
+                    <th class="px-4 py-2 text-right font-medium">Actions</th>
+                </tr>
+            </thead>
+            <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
+                {% for u in list.items|default([]) %}
+                    <tr>
+                        <td class="px-4 py-2 font-mono text-xs text-slate-500 dark:text-slate-400">{{ u.id }}</td>
+                        <td class="px-4 py-2">{{ u.display_name|default('—') }}</td>
+                        <td class="px-4 py-2 text-slate-600 dark:text-slate-400">{{ u.email|default('—') }}</td>
+                        <td class="px-4 py-2 font-mono text-xs">{{ u.role }}</td>
+                        <td class="px-4 py-2 text-xs text-slate-500 dark:text-slate-400">
+                            {% if u.is_local %}local{% else %}oidc{% endif %}
+                        </td>
+                        <td class="px-4 py-2">
+                            {% if u.disabled %}
+                                <span class="rounded bg-amber-100 px-1.5 py-0.5 text-xs uppercase text-amber-900 dark:bg-amber-900 dark:text-amber-100"
+                                      title="{{ u.disabled_at|default('') }}">disabled</span>
+                            {% else %}
+                                <span class="rounded bg-emerald-100 px-1.5 py-0.5 text-xs uppercase text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100">active</span>
+                            {% endif %}
+                        </td>
+                        <td class="px-4 py-2 text-right">
+                            {% if u.id == acting_user_id %}
+                                <span class="text-xs text-slate-400">you</span>
+                            {% elseif u.is_local %}
+                                <span class="text-xs text-slate-400" title="The local admin cannot be disabled here. Unset LOCAL_ADMIN_PASSWORD_HASH in the UI's env to disable local sign-in.">protected</span>
+                            {% elseif u.disabled %}
+                                {% include 'partials/confirm_form.twig' with {
+                                    action: '/app/users/' ~ u.id ~ '/enable',
+                                    label: 'Enable',
+                                    description: 'Re-enabling this user lets them log in again. Their role is recomputed from OIDC groups on next sign-in.',
+                                    btn_class: 'rounded-md border border-emerald-300 bg-white px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-700 dark:bg-slate-900 dark:text-emerald-300 dark:hover:bg-slate-800'
+                                } only %}
+                            {% else %}
+                                {% include 'partials/confirm_form.twig' with {
+                                    action: '/app/users/' ~ u.id ~ '/disable',
+                                    label: 'Disable',
+                                    description: 'A disabled user cannot impersonate via the UI BFF — every admin call 403s until re-enabled.',
+                                } only %}
+                            {% endif %}
+                        </td>
+                    </tr>
+                {% else %}
+                    <tr><td colspan="7" class="px-4 py-6 text-center text-slate-400">No users.</td></tr>
+                {% endfor %}
+            </tbody>
+        </table>
+    </section>
+</div>
+{% endblock %}

+ 4 - 1
ui/resources/views/partials/sidebar.twig

@@ -11,12 +11,15 @@
             { href: '/app/consumers',     label: 'Consumers',     section: 'consumers' },
             { href: '/app/tokens',        label: 'Tokens',        section: 'tokens' },
             { href: '/app/categories',    label: 'Categories',    section: 'categories' },
+            { href: '/app/users',         label: 'Users',         section: 'users',         admin_only: true },
             { href: '/app/audit',         label: 'Audit',         section: 'audit' },
             { href: '/app/settings',      label: 'Settings',      section: 'settings' },
             { href: '/app/me',            label: 'My identity',   section: 'me' },
         ] %}
         {% for link in links %}
-            {% if link.upcoming is defined %}
+            {% if link.admin_only is defined and link.admin_only and (current_user is null or current_user.role != 'admin') %}
+                {# hide admin-only links from non-admins #}
+            {% elseif link.upcoming is defined %}
                 <span class="flex items-center justify-between rounded-md px-3 py-1.5 text-slate-400 dark:text-slate-600">
                     <span>{{ link.label }}</span>
                     <span class="font-mono text-[0.6rem] uppercase tracking-wider">{{ link.upcoming }}</span>

+ 28 - 0
ui/src/ApiClient/AdminClient.php

@@ -363,6 +363,34 @@ final class AdminClient
         $this->api->request('DELETE', '/api/v1/admin/categories/' . $id, [], $actingUserId);
     }
 
+    // ---- users (admin user disable/enable; SEC_REVIEW F11) ----
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function listUsers(int $actingUserId, int $page = 1, int $pageSize = 50): array
+    {
+        return $this->api->request(
+            'GET',
+            '/api/v1/admin/users',
+            ['query' => ['page' => $page, 'page_size' => $pageSize]],
+            $actingUserId,
+        );
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function setUserDisabled(int $actingUserId, int $userId, bool $disabled): array
+    {
+        return $this->api->request(
+            'PATCH',
+            '/api/v1/admin/users/' . $userId,
+            ['json' => ['disabled' => $disabled]],
+            $actingUserId,
+        );
+    }
+
     // ---- audit / settings (M12) ----
 
     /**

+ 7 - 0
ui/src/App/AppFactory.php

@@ -23,6 +23,7 @@ use App\Controllers\ReportersController;
 use App\Controllers\SearchController;
 use App\Controllers\SettingsController;
 use App\Controllers\TokensController;
+use App\Controllers\UsersController;
 use App\Http\AuthRequiredMiddleware;
 use App\Http\CsrfMiddleware;
 use App\Http\JsonExceptionHandler;
@@ -190,6 +191,12 @@ final class AppFactory
             $audit = $container->get(AuditController::class);
             $group->get('/audit', [$audit, 'index']);
 
+            /** @var UsersController $users */
+            $users = $container->get(UsersController::class);
+            $group->get('/users', [$users, 'index']);
+            $group->post('/users/{id}/disable', [$users, 'disable']);
+            $group->post('/users/{id}/enable', [$users, 'enable']);
+
             /** @var SearchController $search */
             $search = $container->get(SearchController::class);
             $group->get('/search', [$search, 'index']);

+ 2 - 0
ui/src/App/Container.php

@@ -33,6 +33,7 @@ use App\Controllers\ReportersController;
 use App\Controllers\SearchController;
 use App\Controllers\SettingsController;
 use App\Controllers\TokensController;
+use App\Controllers\UsersController;
 use App\Http\AuthRequiredMiddleware;
 use App\Http\CsrfMiddleware;
 use App\Http\JsonExceptionHandler;
@@ -238,6 +239,7 @@ final class Container
             ConsumersController::class => autowire(),
             TokensController::class => autowire(),
             CategoriesController::class => autowire(),
+            UsersController::class => autowire(),
             AuditController::class => autowire(),
             SearchController::class => autowire(),
             SettingsController::class => autowire(),

+ 12 - 0
ui/src/Auth/LocalLoginController.php

@@ -115,6 +115,18 @@ final class LocalLoginController
         try {
             $user = $this->auth->upsertLocal($this->localUsername);
         } catch (ApiException $e) {
+            // SEC_REVIEW F11: a disabled local-admin row blocks the
+            // upsert with 403 user_disabled. Don't speak truth about
+            // "disabled" on the public login page (probe-friendly);
+            // surface a generic credential error instead. The audit log
+            // captures the 403 server-side.
+            if ($e->statusCode === 403 && $e->apiError === 'user_disabled') {
+                $this->logger->warning('local login denied: account disabled');
+                $this->throttle->recordFailure($username, $sourceIp);
+                $this->sessions->flash('error', 'Invalid username or password.');
+
+                return $response->withStatus(303)->withHeader('Location', '/login');
+            }
             $this->logger->error('local login: upsertLocal failed', ['error' => $e->getMessage()]);
             $this->sessions->flash('error', 'API unreachable; please retry.');
 

+ 9 - 0
ui/src/Auth/OidcController.php

@@ -88,6 +88,15 @@ final class OidcController
                 groups: $claims->groups,
             );
         } catch (ApiException $e) {
+            // SEC_REVIEW F11: a disabled user surfaces as 403 user_disabled
+            // from the api. Route them to /no-access (same dead-end the
+            // "no role assigned" branch uses) instead of looping back
+            // through /login with an "API unreachable" flash that misleads.
+            if ($e->statusCode === 403 && $e->apiError === 'user_disabled') {
+                $this->logger->warning('oidc login denied: user disabled', ['subject' => $claims->subject]);
+
+                return $response->withStatus(302)->withHeader('Location', '/no-access');
+            }
             $this->logger->error('oidc upsert failed', ['error' => $e->getMessage()]);
             $this->sessions->flash('error', 'API unreachable; please retry.');
 

+ 135 - 0
ui/src/Controllers/UsersController.php

@@ -0,0 +1,135 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\ApiClient\AdminClient;
+use App\ApiClient\ApiException;
+use App\Auth\SessionManager;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Views\Twig;
+
+/**
+ * `/app/users` — admin-only roster + disable/enable toggle.
+ *
+ * SEC_REVIEW F11. The page is a defensive minimum: list every user the
+ * api knows about, and provide one POST per row to flip `disabled`.
+ * Heavier user-management features (delete, role override) are not
+ * exposed yet; OIDC role still flows from `oidc_role_mappings`.
+ */
+final class UsersController
+{
+    use CrudControllerSupport;
+
+    public function __construct(
+        private readonly Twig $twigEngine,
+        private readonly SessionManager $sessionManager,
+        private readonly AdminClient $admin,
+    ) {
+    }
+
+    protected function twig(): Twig
+    {
+        return $this->twigEngine;
+    }
+
+    protected function sessions(): SessionManager
+    {
+        return $this->sessionManager;
+    }
+
+    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        if (!$this->userIs($user, 'admin')) {
+            return $this->twigEngine->render($response->withStatus(403), 'pages/error.twig', [
+                'status' => 403,
+                'is_client_error' => true,
+                'message' => 'Admin role required.',
+            ]);
+        }
+
+        try {
+            $list = $this->admin->listUsers($user->userId);
+        } catch (ApiException $e) {
+            $list = ['items' => [], 'total' => 0];
+            $this->flashFromException($e);
+        }
+
+        return $this->twigEngine->render($response, 'pages/users/index.twig', [
+            'active_section' => 'users',
+            'list' => $list,
+            'acting_user_id' => $user->userId,
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function disable(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        return $this->setDisabled($request, $response, $args, true);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function enable(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        return $this->setDisabled($request, $response, $args, false);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    private function setDisabled(
+        ServerRequestInterface $request,
+        ResponseInterface $response,
+        array $args,
+        bool $disabled,
+    ): ResponseInterface {
+        $redirect = $this->requireUser($request, $response);
+        if ($redirect !== null) {
+            return $redirect;
+        }
+        $user = $this->sessionManager->getUser();
+        \assert($user !== null);
+
+        if (!$this->userIs($user, 'admin')) {
+            return $response->withStatus(303)->withHeader('Location', '/app/users');
+        }
+
+        $id = $this->parseId($args['id']);
+        if ($id === null) {
+            return $response->withStatus(303)->withHeader('Location', '/app/users');
+        }
+
+        try {
+            $this->admin->setUserDisabled($user->userId, $id, $disabled);
+            $this->sessionManager->flash('success', $disabled ? 'User disabled.' : 'User enabled.');
+        } catch (ApiException $e) {
+            // Map api 409s ("cannot_disable_self", "cannot_disable_local_admin")
+            // to a friendly flash; everything else flows through the standard
+            // exception → flash mapper.
+            if ($e->statusCode === 409 && $e->apiError !== null) {
+                $this->sessionManager->flash('error', match ($e->apiError) {
+                    'cannot_disable_self' => 'You cannot disable your own account.',
+                    'cannot_disable_local_admin' => 'The local admin cannot be disabled here. Unset LOCAL_ADMIN_PASSWORD_HASH instead.',
+                    default => $e->apiError,
+                });
+            } else {
+                $this->flashFromException($e);
+            }
+        }
+
+        return $response->withStatus(303)->withHeader('Location', '/app/users');
+    }
+}