Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions packages/gateway/public/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -636,13 +636,16 @@ <h2>Tool catalog <span class="muted">${enabledCount} of ${tools.length} enabled
el.innerHTML = `
<section class="panel"><h2>Users (OIDC logins)</h2>
${users.length ? `<table>
<tr><th>User</th><th>Subject</th><th>Last login</th><th>Role</th></tr>
<tr><th>User</th><th>Subject</th><th>Last login</th><th>Role override</th><th>From groups</th><th></th></tr>
${users.map(u => `<tr>
<td>${esc(u.displayName || u.email || "—")}</td>
<td class="mono">${esc(u.email || u.sub)}</td>
<td class="muted">${esc(u.lastLoginAt || "")}</td>
<td><select data-user="${u.id}">${roleOpts(u.roleId)}</select></td>
</tr>`).join("")}</table>` : '<p class="muted">No OIDC logins yet.</p>'}
<td class="muted">${u.loginRoles?.length ? esc(u.loginRoles.join(", ")) : "—"}</td>
<td><button class="btn danger" data-del-user="${u.id}" data-del-user-label="${esc(u.email || u.sub)}">Delete</button></td>
</tr>`).join("")}</table>
<p class="muted" style="margin:8px 0 0">An override wins over groups; with none, access is the <b>union</b> 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.</p>` : '<p class="muted">No OIDC logins yet.</p>'}
</section>
<section class="panel"><h2>Group → role mappings</h2>
${mappings.length ? `<table>
Expand Down Expand Up @@ -678,6 +681,20 @@ <h2>Tool catalog <span class="muted">${enabledCount} of ${tools.length} enabled
<td><button class="btn danger" data-del-client="${esc(c.clientId)}">Delete</button></td>
</tr>`).join("")}</table>` : '<p class="muted">No registered clients.</p>'}
</section>`;
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 {
Expand Down
39 changes: 39 additions & 0 deletions packages/gateway/src/db/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions packages/gateway/src/http/admin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
31 changes: 31 additions & 0 deletions packages/gateway/src/http/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, {
Expand Down
Loading