Bladeren bron

Fix R01-N23: users.tombstoned_at — soft erasure for privacy requests

Users still cannot be deleted (FK from audit_log), but an admin can now
mark a user as `(former user)` so the live UI hides their email and
display name. Audit rows keep the historical email verbatim — the
trail is the authoritative record and erasure regulations permit
retention for legal / security purposes.

- migrations/006_users_tombstoned.sql: nullable `tombstoned_at`
  column. NULL means active; ISO-8601 UTC stamp means tombstoned.
- src/Domain/User.php: carry `tombstonedAt`, expose `isTombstoned()`,
  `publicEmail()` / `publicDisplayName()` (return `(former user)`
  when tombstoned). Audit snapshot grows a `tombstoned_at` field.
- src/Repositories/UserRepository.php: hydrate the new column;
  `setTombstoned()` returns before/after and forces is_admin=0 on
  tombstone (a tombstoned admin is nonsensical when the UI hides
  the identity). `upsertFromOidc()` clears `tombstoned_at` on a
  successful sign-in — the marker is a display redaction, not an
  access control. The forceAdmin (local-admin) path also clears it.
- src/Controllers/UserController.php: new `tombstone()` action,
  routed at POST /users/{id}/tombstone with a hidden `action`
  field (`tombstone` / `restore`). Pure-static
  `tombstoneGuardrail()` blocks self-tombstone, last-admin
  tombstone (because tombstoning auto-demotes), and short-circuits
  no-ops. Audit action labels are TOMBSTONE and RESTORE on
  entity_type=user.
- views/users/index.twig: tombstoned rows render `(former user)`
  with an amber badge plus a Restore button. Active rows get a
  small `Mark as former user` button alongside the existing admin
  toggle. Self row never offers tombstone.
- views/audit/index.twig: action-class palette grows TOMBSTONE
  (amber) and RESTORE (blue).
- doc/admin-manual.md §5.1.1: new section walking the operator
  through privacy / right-to-be-forgotten requests, the audit-log
  retention stance, and the auto-demote / sign-in-clears semantics.
- SPEC.md §7: TOMBSTONE / RESTORE listed as domain-table actions
  (the row IS being mutated, so they fall under the standard
  one-row-per-change rule, not the non-mutation event list).

Tests: 325 / 853 (was 311 / 823). UserRepositoryTest grew from 9
to 14 cases (untombstone-on-sign-in for both OIDC and forceAdmin
paths, tombstone forcibly demotes, restore-doesn't-promote, and
the `(former user)` label assertion). UserControllerTest grew
from 8 to 16 (tombstoneGuardrail matrix: self / last-admin /
restore-never-trips-last-admin / noop-short-circuits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 2 dagen geleden
bovenliggende
commit
22a3840570

+ 4 - 0
SPEC.md

@@ -262,6 +262,10 @@ is called inside the same transaction as the DB change. Controllers prefer
 `recordForRequest(..., Request, ?User)` to drop the repeated plumbing.
 
 - Every CREATE / UPDATE / DELETE on a domain table → exactly one row.
+- R01-N23 adds two domain-table actions on `users`: `TOMBSTONE` (when
+  an admin marks another user as `(former user)`, also forcing
+  `is_admin = 0`) and `RESTORE` (when the marker is cleared).
+  Same one-row-per-change rule, normal `before` / `after` snapshot.
 - Bulk operations (batch cell save) → one row per changed cell.
 - A no-op UPDATE (canonical-JSON-equal before/after) → no row.
 - FK-cascading deletes must be audited by the controller BEFORE calling the

+ 40 - 0
doc/admin-manual.md

@@ -384,6 +384,46 @@ If both guardrails fire at once — i.e. you are the only admin — the
 only safe escape is to sign in via the local-admin fallback (if
 configured) and promote a second account first.
 
+### 5.1.1 Honouring privacy / right-to-be-forgotten requests (R01-N23)
+
+Users are never deleted — the `users.id` column is a foreign key for
+audit rows and dropping it would break the trail. To honour an
+erasure-of-personal-data request, mark the user as a *former user*
+("tombstone"):
+
+1. Open **Users**.
+2. Click **Mark as former user** under the user's row.
+3. The live UI now shows `(former user)` in place of their email and
+   display name, both on this page and anywhere else the application
+   surfaces a user object. The row keeps its position so audit-log
+   joins and the admin counter still work.
+
+Important details:
+
+- **Tombstoning forces `is_admin = 0`** — a tombstoned admin is
+  nonsensical (the UI hides their identity). The same demotion guard
+  rails apply: you cannot tombstone yourself, and you cannot tombstone
+  the last remaining administrator.
+- **The audit log is unchanged.** `audit_log.user_email` still carries
+  the original address verbatim. Erasure regulations under GDPR /
+  similar frameworks typically permit retention of audit data for
+  legal and security purposes — the Sprint Planner takes that
+  position. If your jurisdiction requires audit-row redaction too,
+  you must edit `app.sqlite` manually and document the redaction
+  procedure separately.
+- **A subsequent successful sign-in clears the tombstone** (OIDC or
+  local-admin). The marker is a *display* redaction, not an access
+  control. If access must be revoked, do that in the OIDC tenant
+  (group / app permission) — the Sprint Planner has no such mechanism
+  of its own.
+- **Restore** is reversible from the same row (via the **Restore**
+  button). It clears `tombstoned_at` but does NOT auto-promote the
+  user; if they need admin again, set the admin checkbox separately.
+
+The audit log records both transitions as `TOMBSTONE` and `RESTORE`
+on `entity_type = user`, so the privacy operation itself is itself
+auditable.
+
 ### 5.2 Backups
 
 Everything that needs a backup lives under `./data/`:

+ 13 - 0
migrations/006_users_tombstoned.sql

@@ -0,0 +1,13 @@
+-- R01-N23: tombstone column for users.
+--
+-- An admin marks a user as a "former user" when their name/email should no
+-- longer be displayed in the live UI (privacy / GDPR-style erasure).
+-- The audit log's `user_email` column still keeps the historical value
+-- verbatim — erasure laws permit retention for legal / security purposes —
+-- so the trail does not break.
+--
+-- Nullable; NULL ⇒ active user. Stamped with a UTC ISO-8601 string when
+-- tombstoned. A subsequent successful OIDC sign-in or an admin "restore"
+-- action clears the column back to NULL.
+
+ALTER TABLE users ADD COLUMN tombstoned_at TEXT;

+ 3 - 2
public/index.php

@@ -209,8 +209,9 @@ $router->get('/workers',         $workerCtrl->index(...));
 $router->post('/workers',        $workerCtrl->create(...));
 $router->post('/workers/{id}',   $workerCtrl->update(...));
 
-$router->get('/users',           $userCtrl->index(...));
-$router->post('/users/{id}',     $userCtrl->update(...));
+$router->get('/users',                   $userCtrl->index(...));
+$router->post('/users/{id}',             $userCtrl->update(...));
+$router->post('/users/{id}/tombstone',   $userCtrl->tombstone(...));
 
 $router->get('/sprints/import',           $importCtrl->newForm(...));
 $router->post('/sprints/import',          $importCtrl->upload(...));

+ 105 - 3
src/Controllers/UserController.php

@@ -14,14 +14,22 @@ use PDO;
 use Throwable;
 
 /**
- * /users — admin-only page for promoting / demoting admin status.
+ * /users — admin-only page for promoting / demoting admin status, and for
+ * tombstoning / restoring users (R01-N23 — privacy / right-to-be-forgotten).
  *
- * Guardrails (server-side, both surface as ?error= codes):
+ * Guardrails (server-side, all surface as ?error= codes):
  *   - You cannot demote yourself.
  *   - You cannot demote the last remaining admin.
+ *   - You cannot tombstone yourself.
+ *   - You cannot tombstone the last remaining admin (tombstoning forces
+ *     is_admin=0, so it's a demotion in disguise).
  *
  * No delete — users are never deleted; they'd orphan audit rows that
- * reference them by id.
+ * reference them by id. Tombstoning is the soft alternative: the row stays,
+ * but the live UI hides email + display name behind `(former user)`.
+ * Audit rows keep the original email verbatim — the trail is the
+ * authoritative record and erasure laws permit retention for legal /
+ * security purposes.
  */
 final class UserController
 {
@@ -133,4 +141,98 @@ final class UserController
         }
         return null;
     }
+
+    /**
+     * Pure guardrail check for tombstone toggles (R01-N23).
+     *
+     *  self_tombstone — the actor is trying to tombstone themselves
+     *  last_admin     — tombstoning forces is_admin=0; if the target is
+     *                   currently the only remaining admin, this would
+     *                   strand the org without one
+     *  noop           — already in the requested state
+     *
+     * Returns null when the action is allowed.
+     */
+    public static function tombstoneGuardrail(
+        int $actorId,
+        int $targetId,
+        bool $targetIsTombstoned,
+        bool $targetIsAdmin,
+        bool $tombstone,
+        int $totalAdmins,
+    ): ?string {
+        if ($tombstone === $targetIsTombstoned) {
+            return 'noop';
+        }
+        if ($tombstone && $targetId === $actorId) {
+            return 'self_tombstone';
+        }
+        if ($tombstone && $targetIsAdmin && $totalAdmins <= 1) {
+            return 'last_admin';
+        }
+        return null;
+    }
+
+    /**
+     * POST /users/{id}/tombstone — toggle tombstoned_at via a hidden
+     * `action` field (`tombstone` or `restore`). The form lives in
+     * `views/users/index.twig`.
+     */
+    public function tombstone(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+
+        $id = (int) $params['id'];
+        $target = $this->users->find($id);
+        if ($target === null) {
+            return Response::redirect('/users?error=not_found');
+        }
+
+        $action = $req->postString('action');
+        if ($action !== 'tombstone' && $action !== 'restore') {
+            return Response::redirect('/users?error=bad_action');
+        }
+        $tombstone = ($action === 'tombstone');
+
+        $err = self::tombstoneGuardrail(
+            actorId:           $actor->id,
+            targetId:          $target->id,
+            targetIsTombstoned: $target->isTombstoned(),
+            targetIsAdmin:     $target->isAdmin,
+            tombstone:         $tombstone,
+            totalAdmins:       $this->users->countAdmins(),
+        );
+        if ($err === 'noop') {
+            return Response::redirect('/users?flash=noop');
+        }
+        if ($err !== null) {
+            return Response::redirect('/users?error=' . $err);
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $result = $this->users->setTombstoned($id, $tombstone);
+            $this->audit->recordForRequest(
+                $tombstone ? 'TOMBSTONE' : 'RESTORE',
+                'user',
+                $id,
+                $result['before']->toAuditSnapshot(),
+                $result['after']->toAuditSnapshot(),
+                $req,
+                $actor,
+            );
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::redirect('/users?error=db_error');
+        }
+
+        return Response::redirect('/users?flash=' . ($tombstone ? 'tombstoned' : 'restored'));
+    }
 }

+ 33 - 7
src/Domain/User.php

@@ -6,6 +6,13 @@ namespace App\Domain;
 
 final class User
 {
+    /**
+     * Public label used in the UI when a user is tombstoned (R01-N23). Audit
+     * rows keep the historical email verbatim; this constant only feeds the
+     * live display layer (Users page, anywhere that surfaces a user object).
+     */
+    public const TOMBSTONE_LABEL = '(former user)';
+
     public function __construct(
         public readonly int     $id,
         public readonly string  $entraOid,
@@ -14,20 +21,39 @@ final class User
         public readonly bool    $isAdmin,
         public readonly string  $createdAt,
         public readonly ?string $lastLoginAt,
+        public readonly ?string $tombstonedAt = null,
     ) {
     }
 
+    public function isTombstoned(): bool
+    {
+        return $this->tombstonedAt !== null;
+    }
+
+    /** Email to display in the live UI — redacted while tombstoned. */
+    public function publicEmail(): string
+    {
+        return $this->isTombstoned() ? self::TOMBSTONE_LABEL : $this->email;
+    }
+
+    /** Display name for the live UI — redacted while tombstoned. */
+    public function publicDisplayName(): string
+    {
+        return $this->isTombstoned() ? self::TOMBSTONE_LABEL : $this->displayName;
+    }
+
     /** Stable row snapshot for audit JSON. */
     public function toAuditSnapshot(): array
     {
         return [
-            'id'            => $this->id,
-            'entra_oid'     => $this->entraOid,
-            'email'         => $this->email,
-            'display_name'  => $this->displayName,
-            'is_admin'      => $this->isAdmin ? 1 : 0,
-            'created_at'    => $this->createdAt,
-            'last_login_at' => $this->lastLoginAt,
+            'id'             => $this->id,
+            'entra_oid'      => $this->entraOid,
+            'email'          => $this->email,
+            'display_name'   => $this->displayName,
+            'is_admin'       => $this->isAdmin ? 1 : 0,
+            'created_at'     => $this->createdAt,
+            'last_login_at'  => $this->lastLoginAt,
+            'tombstoned_at'  => $this->tombstonedAt,
         ];
     }
 }

+ 40 - 2
src/Repositories/UserRepository.php

@@ -78,14 +78,21 @@ final class UserRepository
             return ['user' => $user, 'before' => null];
         }
 
+        // R01-N23: a successful sign-in by a tombstoned user un-tombstones
+        // them. The tombstone is a display-redaction marker, not an access
+        // control. If the user can produce a valid OIDC identity (or the
+        // configured local-admin credentials, where forceAdmin is set), the
+        // org has not actually revoked their access — the `(former user)`
+        // label would lie. The clear is part of the same UPDATE so the
+        // audit `before/after` snapshot captures the transition cleanly.
         if ($forceAdmin) {
             $stmt = $this->pdo->prepare(
-                'UPDATE users SET email = ?, display_name = ?, last_login_at = ?, is_admin = 1 WHERE id = ?'
+                'UPDATE users SET email = ?, display_name = ?, last_login_at = ?, is_admin = 1, tombstoned_at = NULL WHERE id = ?'
             );
             $stmt->execute([$email, $name, $now, $existing->id]);
         } else {
             $stmt = $this->pdo->prepare(
-                'UPDATE users SET email = ?, display_name = ?, last_login_at = ? WHERE id = ?'
+                'UPDATE users SET email = ?, display_name = ?, last_login_at = ?, tombstoned_at = NULL WHERE id = ?'
             );
             $stmt->execute([$email, $name, $now, $existing->id]);
         }
@@ -133,6 +140,34 @@ final class UserRepository
         return ['before' => $before, 'after' => $after];
     }
 
+    /**
+     * R01-N23: tombstone or restore a user. Tombstoning also forces
+     * `is_admin = 0` because a tombstoned admin is nonsensical (the
+     * UI hides the row's name/email). Restore clears the timestamp but
+     * does NOT auto-promote — admin restoration is a separate, explicit
+     * action via setAdmin(). Returns before/after for audit.
+     *
+     * @return array{before: User, after: User}
+     */
+    public function setTombstoned(int $id, bool $tombstoned): array
+    {
+        $before = $this->find($id);
+        if ($before === null) {
+            throw new \RuntimeException("User {$id} not found");
+        }
+        if ($tombstoned) {
+            $this->pdo
+                ->prepare('UPDATE users SET tombstoned_at = ?, is_admin = 0 WHERE id = ?')
+                ->execute([gmdate('Y-m-d\TH:i:s\Z'), $id]);
+        } else {
+            $this->pdo
+                ->prepare('UPDATE users SET tombstoned_at = NULL WHERE id = ?')
+                ->execute([$id]);
+        }
+        $after = $this->find($id) ?? $before;
+        return ['before' => $before, 'after' => $after];
+    }
+
     /**
      * @param array<string,mixed> $row
      */
@@ -148,6 +183,9 @@ final class UserRepository
             lastLoginAt: isset($row['last_login_at']) && $row['last_login_at'] !== null
                 ? (string) $row['last_login_at']
                 : null,
+            tombstonedAt: isset($row['tombstoned_at']) && $row['tombstoned_at'] !== null
+                ? (string) $row['tombstoned_at']
+                : null,
         );
     }
 }

+ 45 - 0
tests/Controllers/UserControllerTest.php

@@ -46,4 +46,49 @@ final class UserControllerTest extends TestCase
             $label,
         );
     }
+
+    /** @return list<array{int,int,bool,bool,bool,int,?string,string}> */
+    public static function tombstoneCases(): array
+    {
+        // [actorId, targetId, isTombstoned, isAdmin, tombstone, totalAdmins, expected, label]
+        return [
+            // self
+            [1, 1, false, false, true,  2, 'self_tombstone', 'tombstoning self, even non-admin'],
+            [1, 1, true,  false, false, 2, null,             'restoring self (whoever did the original tombstone is gone) — allowed'],
+            // last admin guard kicks in only on tombstone, not restore
+            [1, 2, false, true,  true,  1, 'last_admin',     'tombstoning the only remaining admin'],
+            [1, 2, false, true,  true,  2, null,             'tombstoning an admin when another remains'],
+            [1, 2, false, false, true,  1, null,             'tombstoning a non-admin when target is the actor pool — allowed'],
+            // restore never trips last_admin (it merely clears the marker; admin status was already 0)
+            [1, 2, true,  false, false, 1, null,             'restore is always allowed even with one admin'],
+            // noop short-circuits before any other rule
+            [1, 1, true,  false, true,  1, 'noop',           'tombstone of already-tombstoned (and self) → noop wins'],
+            [1, 2, false, false, false, 1, 'noop',           'restore of already-active → noop'],
+        ];
+    }
+
+    #[DataProvider('tombstoneCases')]
+    public function testTombstoneGuardrail(
+        int $actor,
+        int $target,
+        bool $isTombstoned,
+        bool $isAdmin,
+        bool $tombstone,
+        int $totalAdmins,
+        ?string $expected,
+        string $label,
+    ): void {
+        $this->assertSame(
+            $expected,
+            UserController::tombstoneGuardrail(
+                actorId: $actor,
+                targetId: $target,
+                targetIsTombstoned: $isTombstoned,
+                targetIsAdmin: $isAdmin,
+                tombstone: $tombstone,
+                totalAdmins: $totalAdmins,
+            ),
+            $label,
+        );
+    }
 }

+ 107 - 0
tests/Repositories/UserRepositoryTest.php

@@ -153,4 +153,111 @@ final class UserRepositoryTest extends TestCase
         $this->assertFalse($r['after']->isAdmin);
         $this->assertSame(1, $users->countAdmins());
     }
+
+    // ------------------------------------------------------------------
+    // R01-N23: tombstone (soft-delete for privacy)
+    // ------------------------------------------------------------------
+
+    public function testFreshUserIsNotTombstoned(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $r = $users->upsertFromOidc('oid-a', 'a@x', 'Alice', true);
+        $this->assertFalse($r['user']->isTombstoned());
+        $this->assertNull($r['user']->tombstonedAt);
+    }
+
+    public function testSetTombstonedStampsAndForcesDemote(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $users->upsertFromOidc('oid-a', 'a@x', 'A', true); // first admin
+        $users->upsertFromOidc('oid-b', 'b@x', 'B', false);
+        $users->upsertFromOidc('oid-c', 'c@x', 'C', false, true); // forceAdmin → second admin
+
+        $bob = $users->findByOid('oid-b');
+        $r = $users->setTombstoned($bob->id, true);
+
+        $this->assertFalse($r['before']->isTombstoned());
+        $this->assertTrue ($r['after']->isTombstoned());
+        $this->assertNotNull($r['after']->tombstonedAt);
+        $this->assertSame('(former user)', $r['after']->publicEmail());
+        $this->assertSame('(former user)', $r['after']->publicDisplayName());
+        $this->assertSame('b@x',           $r['after']->email,        'audit-snapshot email is preserved');
+        $this->assertSame('B',             $r['after']->displayName,  'audit-snapshot display name is preserved');
+        $this->assertFalse($r['after']->isAdmin, 'tombstone forces is_admin=0');
+    }
+
+    public function testSetTombstonedClearsAdminEvenForCurrentlyAdminUsers(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $users->upsertFromOidc('oid-a', 'a@x', 'A', true); // first admin (kept)
+        $users->upsertFromOidc('oid-b', 'b@x', 'B', false, true); // forceAdmin → second admin
+
+        $bob = $users->findByOid('oid-b');
+        $this->assertTrue($bob->isAdmin);
+
+        $users->setTombstoned($bob->id, true);
+        $bob = $users->findByOid('oid-b');
+        $this->assertFalse($bob->isAdmin, 'tombstoning an admin must demote them');
+        $this->assertSame(1, $users->countAdmins());
+    }
+
+    public function testRestoreClearsTombstoneButDoesNotPromote(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $users->upsertFromOidc('oid-a', 'a@x', 'A', true);
+        $users->upsertFromOidc('oid-b', 'b@x', 'B', false, true);
+
+        $bob = $users->findByOid('oid-b');
+        $users->setTombstoned($bob->id, true);
+
+        $bob = $users->findByOid('oid-b');
+        $this->assertTrue($bob->isTombstoned());
+        $this->assertFalse($bob->isAdmin, 'precondition: tombstone demoted bob');
+
+        $r = $users->setTombstoned($bob->id, false);
+        $this->assertFalse($r['after']->isTombstoned());
+        $this->assertNull($r['after']->tombstonedAt);
+        $this->assertFalse($r['after']->isAdmin, 'restore must NOT auto-promote');
+    }
+
+    public function testOidcSignInUntombstones(): void
+    {
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $users->upsertFromOidc('oid-a', 'a@x', 'A', true);
+        $alice = $users->findByOid('oid-a');
+        $users->setTombstoned($alice->id, true);
+        $this->assertTrue($users->findByOid('oid-a')->isTombstoned());
+
+        // A successful OIDC sign-in (the upsert path the AuthController calls)
+        // must clear the tombstone — the user has demonstrated they still hold
+        // the identity, so the `(former user)` label would lie.
+        $r = $users->upsertFromOidc('oid-a', 'a@x', 'A', false);
+        $this->assertFalse($r['user']->isTombstoned());
+    }
+
+    public function testForceAdminAlsoUntombstones(): void
+    {
+        // Local-admin login path: forceAdmin=true also clears any
+        // tombstone, so a tombstoned configured admin can come back.
+        $pdo   = $this->makeDb();
+        $users = new UserRepository($pdo);
+
+        $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', true, true);
+        $admin = $users->findByOid('local:admin@x');
+        $users->setTombstoned($admin->id, true);
+
+        $r = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', false, true);
+        $this->assertFalse($r['user']->isTombstoned());
+        $this->assertTrue($r['user']->isAdmin, 'forceAdmin path still re-promotes');
+    }
 }

+ 2 - 0
views/audit/index.twig

@@ -4,6 +4,8 @@
     'CREATE':          'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
     'UPDATE':          'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
     'DELETE':          'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
+    'TOMBSTONE':       'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
+    'RESTORE':         'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
     'LOGIN':           'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
     'LOGOUT':          'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
     'LOGIN_FAILED':    'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',

+ 78 - 33
views/users/index.twig

@@ -1,15 +1,19 @@
 {% extends "layout.twig" %}
 
 {% set errorMessages = {
-    'self_demote': 'You cannot demote yourself — ask another admin.',
-    'last_admin':  'Cannot demote the last remaining admin.',
-    'not_found':   'User not found.',
-    'db_error':    'Could not save. Try again.',
+    'self_demote':    'You cannot demote yourself — ask another admin.',
+    'last_admin':     'Cannot demote the last remaining admin.',
+    'self_tombstone': 'You cannot tombstone yourself — ask another admin.',
+    'bad_action':     'Unrecognised action.',
+    'not_found':      'User not found.',
+    'db_error':       'Could not save. Try again.',
 } %}
 {% set flashMessages = {
-    'promoted': 'Admin granted.',
-    'demoted':  'Admin revoked.',
-    'noop':     'Nothing changed.',
+    'promoted':   'Admin granted.',
+    'demoted':    'Admin revoked.',
+    'tombstoned': 'User marked as former — name and email are now hidden in the UI. Audit trail keeps the original values.',
+    'restored':   'User restored.',
+    'noop':       'Nothing changed.',
 } %}
 
 {% block content %}
@@ -19,7 +23,9 @@
         <p class="text-slate-600 text-sm mt-1 max-w-prose dark:text-slate-400">
             Everyone who has ever signed in. Toggle admin status here; you
             cannot demote yourself or the last admin. Users are never deleted
-            — inactive accounts simply stop signing in.
+            — to honour a privacy / right-to-be-forgotten request, mark a
+            user as <em>former</em>: the live UI hides their name and email
+            (the audit log keeps the original values verbatim).
         </p>
     </div>
 
@@ -50,37 +56,76 @@
                 </thead>
                 <tbody class="divide-y divide-slate-100 dark:divide-slate-700">
                     {% for u in users %}
-                        {% set isSelf = u.id == currentUser.id %}
-                        <tr>
-                            <form method="post" action="/users/{{ u.id }}" hx-boost="true" hx-target="body" class="contents">
-                                <input type="hidden" name="_csrf" value="{{ csrfToken }}">
-                                <td class="px-4 py-2 font-mono text-xs">
-                                    {{ u.email }}
-                                    {% if isSelf %}
-                                        <span class="ml-1 inline-block px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider bg-slate-100 text-slate-700 rounded dark:bg-slate-700 dark:text-slate-200">you</span>
-                                    {% endif %}
+                        {% set isSelf       = u.id == currentUser.id %}
+                        {% set isTombstoned = u.isTombstoned %}
+                        {% if isTombstoned %}
+                            <tr class="bg-slate-50 dark:bg-slate-900/40">
+                                <td class="px-4 py-2 font-mono text-xs italic text-slate-500 dark:text-slate-400">
+                                    {{ u.publicEmail }}
+                                    <span class="ml-1 inline-block px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider bg-amber-100 text-amber-800 rounded dark:bg-amber-900 dark:text-amber-200">former</span>
                                 </td>
-                                <td class="px-4 py-2">{{ u.displayName }}</td>
+                                <td class="px-4 py-2 italic text-slate-500 dark:text-slate-400">{{ u.publicDisplayName }}</td>
                                 <td class="px-4 py-2 text-slate-500 font-mono text-xs dark:text-slate-400">
                                     {% if u.lastLoginAt is not null %}{{ u.lastLoginAt }}{% else %}<span class="text-slate-400 dark:text-slate-500">—</span>{% endif %}
                                 </td>
-                                <td class="px-4 py-2">
-                                    <label class="inline-flex items-center gap-2">
-                                        <input name="is_admin" type="checkbox" value="1"
-                                               {{ u.isAdmin ? 'checked' : '' }}
-                                               {% if isSelf and u.isAdmin %}disabled title="You cannot demote yourself"{% endif %}
-                                               class="rounded border-slate-300 dark:border-slate-600">
-                                        <span class="text-slate-600 dark:text-slate-400">admin</span>
-                                    </label>
-                                </td>
+                                <td class="px-4 py-2 text-slate-400 dark:text-slate-500">—</td>
                                 <td class="px-4 py-2 text-right">
-                                    <button type="submit"
-                                            class="rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-1 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
-                                        Save
-                                    </button>
+                                    <form method="post" action="/users/{{ u.id }}/tombstone" hx-boost="true" hx-target="body">
+                                        <input type="hidden" name="_csrf" value="{{ csrfToken }}">
+                                        <input type="hidden" name="action" value="restore">
+                                        <button type="submit"
+                                                class="rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-1 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                                            Restore
+                                        </button>
+                                    </form>
                                 </td>
-                            </form>
-                        </tr>
+                            </tr>
+                        {% else %}
+                            <tr>
+                                <form method="post" action="/users/{{ u.id }}" hx-boost="true" hx-target="body" class="contents">
+                                    <input type="hidden" name="_csrf" value="{{ csrfToken }}">
+                                    <td class="px-4 py-2 font-mono text-xs">
+                                        {{ u.email }}
+                                        {% if isSelf %}
+                                            <span class="ml-1 inline-block px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider bg-slate-100 text-slate-700 rounded dark:bg-slate-700 dark:text-slate-200">you</span>
+                                        {% endif %}
+                                    </td>
+                                    <td class="px-4 py-2">{{ u.displayName }}</td>
+                                    <td class="px-4 py-2 text-slate-500 font-mono text-xs dark:text-slate-400">
+                                        {% if u.lastLoginAt is not null %}{{ u.lastLoginAt }}{% else %}<span class="text-slate-400 dark:text-slate-500">—</span>{% endif %}
+                                    </td>
+                                    <td class="px-4 py-2">
+                                        <label class="inline-flex items-center gap-2">
+                                            <input name="is_admin" type="checkbox" value="1"
+                                                   {{ u.isAdmin ? 'checked' : '' }}
+                                                   {% if isSelf and u.isAdmin %}disabled title="You cannot demote yourself"{% endif %}
+                                                   class="rounded border-slate-300 dark:border-slate-600">
+                                            <span class="text-slate-600 dark:text-slate-400">admin</span>
+                                        </label>
+                                    </td>
+                                    <td class="px-4 py-2 text-right">
+                                        <button type="submit"
+                                                class="rounded-md border border-slate-300 bg-white text-slate-700 px-3 py-1 text-sm hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                                            Save
+                                        </button>
+                                    </td>
+                                </form>
+                            </tr>
+                            {% if not isSelf %}
+                                <tr>
+                                    <td colspan="5" class="px-4 pb-2 pt-0 text-right">
+                                        <form method="post" action="/users/{{ u.id }}/tombstone" hx-boost="true" hx-target="body">
+                                            <input type="hidden" name="_csrf" value="{{ csrfToken }}">
+                                            <input type="hidden" name="action" value="tombstone">
+                                            <button type="submit"
+                                                    class="text-xs text-amber-700 hover:underline dark:text-amber-300">
+                                                Mark as former user
+                                            </button>
+                                        </form>
+                                    </td>
+                                </tr>
+                            {% endif %}
+                        {% endif %}
                     {% endfor %}
                 </tbody>
             </table>