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}`, {