From 86920ed3dedf7626b44b4d84e3b7c8eb76eba7f9 Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Fri, 14 Aug 2026 18:18:52 -0400 Subject: [PATCH] admin: forget a user (row, prefs, credential refs, tokens, sessions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod accumulated login rows nobody wants — e.g. an Azure admin account that signed in once and has no role and no groups. There was no way to remove one. DELETE /api/users/:id removes the row, its group-derived roles (FK cascade), the personal prefs and credential REFS keyed to that identity, revokes any live refresh tokens, and drops the principal's live MCP sessions. Deliberately NOT an access revocation, and the UI says so: anyone still in a mapped group gets a fresh row at their next login. This is for clearing accounts that will not come back. Secret-store values behind credential refs are not touched — that is issue #9 — so the response says so when there were any, instead of implying a clean wipe. The Users tab also grows a "From groups" column, since with additive roles an override and the group-derived set are different things and the old single "Role" column could not show both. Co-Authored-By: Claude Opus 5 --- packages/gateway/public/admin.html | 21 ++++++++++++-- packages/gateway/src/db/repo.ts | 39 ++++++++++++++++++++++++++ packages/gateway/src/http/admin-api.ts | 31 ++++++++++++++++++++ packages/gateway/src/http/app.test.ts | 31 ++++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/packages/gateway/public/admin.html b/packages/gateway/public/admin.html index 4a19e43..a25bcb1 100644 --- a/packages/gateway/public/admin.html +++ b/packages/gateway/public/admin.html @@ -636,13 +636,16 @@

Tool catalog ${enabledCount} of ${tools.length} enabled el.innerHTML = `

Users (OIDC logins)

${users.length ? ` - + ${users.map(u => ` - `).join("")}
UserSubjectLast loginRole
UserSubjectLast loginRole overrideFrom groups
${esc(u.displayName || u.email || "—")} ${esc(u.email || u.sub)} ${esc(u.lastLoginAt || "")}
` : '

No OIDC logins yet.

'} + ${u.loginRoles?.length ? esc(u.loginRoles.join(", ")) : "—"} + + `).join("")} +

An override wins over groups; with none, access is the union of every mapped group's role. Deleting a user forgets their row, prefs, credential refs and sessions — it does not revoke access, since a mapped group brings them back at the next login.

` : '

No OIDC logins yet.

'}

Group → role mappings

${mappings.length ? ` @@ -678,6 +681,20 @@

Tool catalog ${enabledCount} of ${tools.length} enabled

`).join("")}
` : '

No registered clients.

'}
`; + el.addEventListener("click", async (ev) => { + const b = ev.target.closest("[data-del-user]"); + if (!b) return; + const label = b.dataset.delUserLabel; + if (!confirm(`Forget ${label}? + +Removes their row, personal prefs, credential refs and live sessions. It does NOT revoke access: if a mapped group still contains them, they return at the next login.`)) return; + try { + const r = await api(`/users/${b.dataset.delUser}`, { method: "DELETE" }); + toast(`Deleted ${label} — ${r.prefs} pref(s), ${r.credentials} credential ref(s), ${r.sessionsClosed} session(s)`); + if (r.note) toast(r.note); + renderUsers(); + } catch (e) { toast(e.message, true); } + }); el.onchange = async (ev) => { const s = ev.target; if (!s.dataset.user) return; try { diff --git a/packages/gateway/src/db/repo.ts b/packages/gateway/src/db/repo.ts index fbcb0d2..df987ae 100644 --- a/packages/gateway/src/db/repo.ts +++ b/packages/gateway/src/db/repo.ts @@ -427,6 +427,45 @@ export class Repo { ).map(mapUser); } + /** + * Remove a user row and everything keyed to that identity: group-derived + * roles (FK cascade), personal prefs, credential REFS, and any live refresh + * tokens. Deliberately NOT an access revocation — someone still in a mapped + * group simply gets a fresh row at their next login; this is for clearing + * accounts that will never come back (a stale admin login, a departed user). + * Secret-store values behind the credential refs are not touched (issue #9). + */ + deleteUser(userId: number): { principal: string; prefs: number; credentials: number; refreshTokens: number } | null { + const user = (this.db.prepare("SELECT id, iss, sub FROM users WHERE id = ?").get(userId) ?? null) as + | { iss: string; sub: string } + | null; + if (!user) return null; + const principal = `oidc:${user.iss}|${user.sub}`; + this.db.exec("BEGIN"); + try { + const prefs = this.db.prepare("DELETE FROM user_prefs WHERE principal = ?").run(principal).changes; + const credentials = this.db + .prepare("DELETE FROM user_credentials WHERE principal = ?") + .run(principal).changes; + const refreshTokens = this.db + .prepare( + "UPDATE oauth_refresh_tokens SET revoked_at = datetime('now') WHERE principal_iss = ? AND principal_sub = ? AND revoked_at IS NULL" + ) + .run(user.iss, user.sub).changes; + this.db.prepare("DELETE FROM users WHERE id = ?").run(userId); + this.db.exec("COMMIT"); + return { + principal, + prefs: Number(prefs), + credentials: Number(credentials), + refreshTokens: Number(refreshTokens), + }; + } catch (err) { + this.db.exec("ROLLBACK"); + throw err; + } + } + /** Admin/bootstrap override — REPLACES whatever the user's groups map to. */ setUserRole(userId: number, roleId: number | null): boolean { const result = this.db diff --git a/packages/gateway/src/http/admin-api.ts b/packages/gateway/src/http/admin-api.ts index 2ada367..16965b4 100644 --- a/packages/gateway/src/http/admin-api.ts +++ b/packages/gateway/src/http/admin-api.ts @@ -540,6 +540,37 @@ export function createAdminRouter(deps: AppDeps, admin: AdminDeps): Router { }) ); + /** + * Forget a user: the row, their group-derived roles, personal prefs, + * credential refs, refresh tokens, and any live session. NOT a revocation — + * anyone still in a mapped group returns on their next login — so the + * response says what actually happened rather than implying more. + */ + router.delete( + "/users/:id", + h((req, res) => { + const removed = repo.deleteUser(Number(param(req, "id"))); + if (!removed) { + res.status(404).json({ error: "Unknown user" }); + return; + } + const sessionsClosed = admin.reloadSessions( + (session) => prefsIdentity(session.principal) === removed.principal + ); + admin.onPolicyChanged(); + console.error(`[admin] deleted user ${removed.principal} (${sessionsClosed} session(s) closed)`); + res.json({ + ok: true, + ...removed, + sessionsClosed, + note: + removed.credentials > 0 + ? "Credential refs removed; the values still exist in the secret store and need deleting there." + : undefined, + }); + }) + ); + router.get( "/group-mappings", h(async (_req, res) => { diff --git a/packages/gateway/src/http/app.test.ts b/packages/gateway/src/http/app.test.ts index d15af3e..8c96e54 100644 --- a/packages/gateway/src/http/app.test.ts +++ b/packages/gateway/src/http/app.test.ts @@ -625,6 +625,37 @@ describe("session reload", () => { }); }); +describe("forgetting a user", () => { + it("removes the row and everything keyed to that identity, and 404s twice", async () => { + const user = repo.upsertUserOnLogin({ iss: "https://idp", sub: "gone", email: "gone@test" }); + const principal = "oidc:https://idp|gone"; + repo.setUserPref(principal, "fake", "read_thing", false); + repo.setLoginRoles(user.id, [repo.roleByName("viewer")!.id]); + + const del = async () => + fetch(`${base}/api/users/${user.id}`, { + method: "DELETE", + headers: { Authorization: "Bearer tok-admin" }, + }); + + expect((await del()).status).toBe(200); // idempotent-ish: the second try 404s + expect(repo.listUsers().some((u) => u.sub === "gone")).toBe(false); + expect(repo.listUserPrefs(principal)).toHaveLength(0); + expect(repo.loginRoles(user.id)).toHaveLength(0); + expect((await del()).status).toBe(404); + }); + + it("is admin-only", async () => { + const user = repo.upsertUserOnLogin({ iss: "https://idp", sub: "keep-me" }); + const asViewer = await fetch(`${base}/api/users/${user.id}`, { + method: "DELETE", + headers: { Authorization: "Bearer tok-viewer" }, + }); + expect(asViewer.status).toBe(403); + expect(repo.listUsers().some((u) => u.sub === "keep-me")).toBe(true); + }); +}); + describe("bulk catalog toggle", () => { const bulk = (body: unknown, upstream = "fake") => fetch(`${base}/api/catalog/${upstream}`, {