diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index e7c0367b0..cbc8f4f1c 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -2905,6 +2905,30 @@ text-overflow: ellipsis; white-space: nowrap; } + .environment-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: 0 0 18px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--warn) 42%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--warn) 8%, var(--surface)); + } + .environment-notice strong, + .environment-notice p { + display: block; + margin: 0; + } + .environment-notice p { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + } + .environment-notice button { + flex: none; + } .governance-overview { margin: 0 0 22px; padding: 18px 20px; @@ -3987,6 +4011,13 @@

Governance

ScopeOrganization +

Effective state

@@ -5717,6 +5748,7 @@

Confirm governance change

}); let scopeDir = null; + let environmentDir = []; let scopeDirNote = "Loading scopes…"; async function loadScopeDirectory() { const r = await api("GET", "/api/scopes"); @@ -5728,6 +5760,7 @@

Confirm governance change

} viewLoadedAt.history = Date.now(); scopeDir = r.data.scopes || []; + environmentDir = r.data.environments || []; if (SCOPED.has(view) && !urlToState().session) { const memoryEditor = view === "memory" && !(orgWideView() && urlToState().mem !== "edit"); @@ -6244,6 +6277,19 @@

Confirm governance change

); } window.addEventListener("scroll", syncGovernanceSectionNav, { passive: true }); + function renderEnvironmentNotice(data) { + const notice = $("environment-notice"); + const attachment = data?.environmentAttachment; + notice.classList.toggle("hidden", !attachment); + if (!attachment) return; + const name = attachment.environmentName || shortName(attachment.environmentId); + $("environment-notice-title").textContent = "Uses named environment " + name; + $("environment-notice-detail").textContent = + "Computer files and working memory resolve to this environment. Governance and conversation history remain scoped here."; + $("environment-notice-open").textContent = "Open " + name; + $("environment-notice-open").onclick = () => + go({ view: "governance", scope: attachment.environmentId, session: null, page: 1 }); + } let governanceReq = 0; async function loadScope() { const requestedScope = scope; @@ -6259,6 +6305,7 @@

Confirm governance change

); return; } + renderEnvironmentNotice(r.data); renderGovernanceOverview(r.data); syncGovernanceSectionNav(); loadedCommandPolicyPresent = r.data.commandPolicy != null; @@ -10707,11 +10754,14 @@

Confirm governance change

function renderUsers(root, d) { const users = d.users || []; const grants = d.grants || []; + const samePrincipal = (a, b) => + a === b || (a.includes("@") && b.includes("@") && a.toLowerCase() === b.toLowerCase()); let filterVal = ""; defaultShell({ stats: [ [users.length, "Users"], [grants.length, "Admins"], + [users.filter((u) => u.deactivation).length, "Deactivated"], ], search: { placeholder: "principal, role, or scope", @@ -10818,7 +10868,11 @@

Confirm governance change

const members = await dirMembers(v); if (members.length > 1) return setStatus("st-imp", "Matches several people — pick one from the suggestions.", "err"); - await openWebUiAs(members[0]?.principalId ?? v); + const target = members[0]?.principalId ?? v; + if (users.find((u) => samePrincipal(u.principalId, target))?.deactivation) { + return setStatus("st-imp", "Reactivate this account before impersonating it.", "err"); + } + await openWebUiAs(target); } catch (e) { setStatus("st-imp", "Couldn't resolve that (" + (e?.message || "directory lookup failed") + ").", "err"); } finally { @@ -10847,7 +10901,12 @@

Confirm governance change

lists.textContent = ""; const filteredGrants = grants.filter((g) => matches([g.principalId, g.role, g.scopeId, g.grantedBy || ""])); const filteredUsers = users.filter((u) => - matches([u.principalId, u.admin?.role || "", u.admin?.scopeId || "member"]), + matches([ + u.principalId, + u.admin?.role || "", + u.admin?.scopeId || "member", + u.deactivation ? "deactivated " + (u.deactivation.source || "") : "active", + ]), ); const admins = actionTable( @@ -10875,47 +10934,76 @@

Confirm governance change

); const roster = openableTable( - ["Principal", "Role", "Last seen", "Sessions", "Turns", "", ""], + ["Principal", "Role", "Access", "Last seen", "Sessions", "Turns", "", ""], filteredUsers, (u) => [ { text: u.principalId, cls: "mono" }, nodeCell(mutedText(u.admin?.isAdmin ? labelRole(u.admin.role) : "member")), + nodeCell(mutedText(u.deactivation ? "deactivated" : "active", u.deactivation ? "err" : "ok")), u.lastSeenAt ? timeCell(u.lastSeenAt) : { text: "-", cls: "num" }, { text: String(u.sessionCount), cls: "num" }, { text: String(u.turnCount), cls: "num" }, { action: { label: "Impersonate ↗", + disabled: !!u.deactivation, + title: u.deactivation ? "Reactivate this account before impersonating it." : "", run: (ev) => { ev?.stopPropagation?.(); openWebUiAs(u.principalId); }, }, }, - u.admin?.isAdmin - ? { text: "" } - : { + u.deactivation + ? { action: { - label: "Make admin", - run: (ev) => { + label: "Reactivate", + run: async (ev) => { ev?.stopPropagation?.(); - pInput.value = u.principalId; - pInput.focus(); - form.scrollIntoView({ block: "center" }); + const button = ev?.currentTarget; + if (button) button.disabled = true; + try { + const r = await api( + "POST", + "/api/users/" + encodeURIComponent(u.principalId) + "/reactivate", + ); + if (r.ok) renderData(); + else setStatus("st-user-access-list", r.data?.message || "Reactivation failed.", "err"); + } catch (e) { + setStatus("st-user-access-list", e?.message || "Reactivation failed.", "err"); + } finally { + if (button?.isConnected) button.disabled = false; + } + }, + }, + } + : u.admin?.isAdmin + ? { text: "" } + : { + action: { + label: "Make admin", + run: (ev) => { + ev?.stopPropagation?.(); + pInput.value = u.principalId; + pInput.focus(); + form.scrollIntoView({ block: "center" }); + }, }, }, - }, ], (u) => go({ view: "user", scope, session: null, principal: u.principalId }), "No users match.", ); - lists.appendChild( - dataCard( - "Users", - "Everyone who has used the agent — click a row for their activity, artifacts, and config.", - roster, - ), + const usersCard = dataCard( + "Users", + "Everyone who has used the agent — click a row for their activity, artifacts, and config.", + roster, ); + const usersStatus = document.createElement("p"); + usersStatus.className = "status"; + usersStatus.id = "st-user-access-list"; + usersCard.querySelector(".body").appendChild(usersStatus); + lists.appendChild(usersCard); }; drawLists(); } @@ -11451,6 +11539,21 @@

Confirm governance change

actions: [sortControl], }); const activityTime = (s) => (scopeSort === "human" ? s.lastConversationActivity || 0 : s.lastActivity || 0); + if (environmentDir.length) { + const environments = denseList( + environmentDir, + (environment) => ({ + name: environment.name || shortName(environment.id), + preview: plural(environment.attachedScopes?.length || 0, "attached scope"), + href: stateToUrl({ view: "history", scope: environment.id, historyKind }), + }), + (environment) => selectScope(environment.id), + "No named environments.", + ); + root.appendChild( + dataCard("Named environments", "Named computers and working memory that scopes can share.", environments), + ); + } const t = denseList( activeRows, (s) => { @@ -11703,13 +11806,75 @@

Confirm governance change

pageShell({ back: { label: "← Users", onClick: () => history.back() }, title: d.displayName || d.principalId || principalId, - context: [d.admin?.isAdmin ? labelRole(d.admin.role) : "member", d.scopeId || ""].filter(Boolean).join(" · "), + context: [ + d.admin?.isAdmin ? labelRole(d.admin.role) : "member", + d.deactivation ? "deactivated" : "active", + d.scopeId || "", + ] + .filter(Boolean) + .join(" · "), }); const userActions = document.createElement("div"); userActions.style.margin = "0 0 14px"; - userActions.appendChild(webUiAsButton(d.principalId || principalId)); + const impersonate = webUiAsButton(d.principalId || principalId); + if (d.deactivation) { + impersonate.disabled = true; + impersonate.title = "Reactivate this account before impersonating it."; + } + userActions.appendChild(impersonate); + if (d.deactivation) { + const reactivate = document.createElement("button"); + reactivate.type = "button"; + reactivate.className = "primary"; + reactivate.style.marginLeft = "8px"; + reactivate.textContent = "Reactivate account"; + reactivate.onclick = async () => { + reactivate.disabled = true; + try { + const response = await api( + "POST", + "/api/users/" + encodeURIComponent(d.principalId || principalId) + "/reactivate", + ); + if (response.ok) return void showUserDetail(principalId); + setStatus("st-user-access", response.data?.message || "Reactivation failed.", "err"); + } catch (e) { + setStatus("st-user-access", e?.message || "Reactivation failed.", "err"); + } finally { + reactivate.disabled = false; + } + }; + userActions.appendChild(reactivate); + } detail.appendChild(userActions); + const accessState = document.createElement("div"); + accessState.appendChild( + table( + ["State", "Source", "Changed"], + [ + [ + nodeCell(mutedText(d.deactivation ? "Deactivated" : "Active", d.deactivation ? "err" : "ok")), + d.deactivation?.source || "—", + d.deactivation?.at ? fmtTime(d.deactivation.at) : "—", + ], + ], + "", + ), + ); + const accessStatus = document.createElement("p"); + accessStatus.className = "status"; + accessStatus.id = "st-user-access"; + accessState.appendChild(accessStatus); + detail.appendChild( + dataCard( + "Account access", + d.deactivation + ? "This identity cannot use QM until an org admin reactivates it." + : "This identity can use QM.", + accessState, + ), + ); + const stats = d.stats || {}; detail.appendChild( kpis([ diff --git a/plugins/admin/test/account-access-view.test.ts b/plugins/admin/test/account-access-view.test.ts new file mode 100644 index 000000000..ef996ef43 --- /dev/null +++ b/plugins/admin/test/account-access-view.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const html = readFileSync(new URL("../public/index.html", import.meta.url), "utf8"); + +test("the users surface shows deactivation state and offers recovery", () => { + assert.match(html, /users\.filter\(\(u\) => u\.deactivation\)\.length, "Deactivated"/); + assert.match(html, /label: "Reactivate"/); + assert.match(html, /"Account access"/); + assert.match(html, /"Reactivate account"/); + assert.match(html, /impersonate\.disabled = true/); + assert.match(html, /Reactivate this account before impersonating it/); + assert.match(html, /st-user-access-list/); + assert.match(html, /catch \(e\)[\s\S]*Reactivation failed/); + assert.match(html, /finally \{[\s\S]*reactivate\.disabled = false/); + assert.match(html, /\/reactivate/); +}); diff --git a/plugins/admin/test/environments.test.ts b/plugins/admin/test/environments.test.ts new file mode 100644 index 000000000..3ec8c9fb8 --- /dev/null +++ b/plugins/admin/test/environments.test.ts @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const html = readFileSync(join(import.meta.dirname, "../public/index.html"), "utf8"); + +test("the admin UI lists named environments and links attachment warnings", () => { + assert.match(html, /Named environments/); + assert.match(html, /id="environment-notice"/); + assert.match(html, /Uses named environment/); + assert.match(html, /scope: attachment\.environmentId/); +}); diff --git a/plugins/admin/test/grants.test.ts b/plugins/admin/test/grants.test.ts index 68d2043f4..7cc249138 100644 --- a/plugins/admin/test/grants.test.ts +++ b/plugins/admin/test/grants.test.ts @@ -72,6 +72,19 @@ test("GET /api/users forwards to /v1/admin/users", async () => { assert.equal(c.actor, "U-admin@acme"); }); +test("POST /api/users/:id/reactivate forwards the recovery request", async () => { + const r = await fetch(`${base}/api/users/locked%40example.com/reactivate`, { + method: "POST", + headers: { cookie: ADMIN }, + }); + assert.equal(r.status, 200); + const c = calls.at(-1)!; + assert.equal(c.method, "POST"); + assert.equal(c.url, "/v1/admin/users/locked%40example.com/reactivate"); + assert.equal(c.actor, "U-admin@acme"); + assert.equal(c.signed, true); +}); + test("GET /api/keychain forwards to /v1/admin/keychain", async () => { const r = await fetch(`${base}/api/keychain`, { headers: { cookie: ADMIN } }); assert.equal(r.status, 200); diff --git a/plugins/web-ui/server/index.ts b/plugins/web-ui/server/index.ts index 08d0b2a46..fc0b9ead0 100644 --- a/plugins/web-ui/server/index.ts +++ b/plugins/web-ui/server/index.ts @@ -447,7 +447,7 @@ async function coreFetchCap( rawBody = "", ): Promise<{ status: number; text: string }> { const cap = await coreFetch("POST", "/v1/session-cap", ""); - if (cap.status !== 200) return { status: cap.status === 401 ? 401 : 503, text: cap.text }; + if (cap.status !== 200) return { status: cap.status >= 400 && cap.status < 500 ? cap.status : 503, text: cap.text }; let token: string | undefined; try { token = (JSON.parse(cap.text) as { token?: string }).token; @@ -555,6 +555,15 @@ function declaredSha(url: URL): string { return url.searchParams.get("sha") ?? ""; } +async function allowPortalUpload(req: IncomingMessage, res: ServerResponse): Promise { + if (AUTH_MODE !== "portal") return true; + const access = await coreFetch("POST", "/v1/session-cap", ""); + if (access.status === 200) return true; + req.resume(); + relay(res, access); + return false; +} + async function uploadBlobFromRequest(req: IncomingMessage, res: ServerResponse, sha256: string): Promise { if (!/^[0-9a-f]{64}$/.test(sha256)) { req.resume(); @@ -768,6 +777,10 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { if (!user) return unauthorized(res, req); if (path === "/me") { + if (AUTH_MODE === "portal") { + const access = await coreFetch("POST", "/v1/session-cap", ""); + if (access.status !== 200) return relay(res, access); + } res.setHeader("set-cookie", sessionCookie(user)); const permissions = await userPermissions(); return json(res, 200, { @@ -781,10 +794,12 @@ const routeRequest = async (req: IncomingMessage, res: ServerResponse) => { } if (method === "POST" && path === "/api/blobs") { + if (!(await allowPortalUpload(req, res))) return; return uploadBlobFromRequest(req, res, declaredSha(url)); } if (method === "POST" && path === "/api/files/upload") { + if (!(await allowPortalUpload(req, res))) return; return uploadFileFromRequest( req, res, diff --git a/plugins/web-ui/src/core-bridge.ts b/plugins/web-ui/src/core-bridge.ts index a7cef34db..f3cc3b3e7 100644 --- a/plugins/web-ui/src/core-bridge.ts +++ b/plugins/web-ui/src/core-bridge.ts @@ -416,7 +416,7 @@ async function toCoreAttachment(a: PiAttachment): Promise { body: bytes as unknown as BodyInit, }); if (!r.ok) { - if (r.status === 401) reportSigninRequired(await r.json().catch(() => ({}))); + if (r.status === 401 || r.status === 403) reportAccessGate(r.status, await r.json().catch(() => ({}))); throw new ApiError(`attachment upload failed: HTTP ${r.status}`, r.status); } const { blobId, sizeBytes } = (await r.json()) as { blobId: string; sizeBytes: number }; @@ -436,7 +436,7 @@ export class ApiError extends Error { export interface SigninRequired { mode?: "portal" | "dev"; - reason?: "unauthenticated" | "not_allowed"; + reason?: "unauthenticated" | "not_allowed" | "account_deactivated"; } let onSigninRequired: ((detail: SigninRequired) => void) | null = null; @@ -449,6 +449,14 @@ export function reportSigninRequired(detail: SigninRequired): void { onSigninRequired?.(detail); } +export function reportAccessGate(status: number, body: unknown): void { + const detail = body as SigninRequired & { error?: string }; + const reason = detail.reason ?? (detail.error === "account_deactivated" ? "account_deactivated" : undefined); + if (status === 401 || (status === 403 && reason === "account_deactivated")) { + reportSigninRequired({ ...detail, ...(reason ? { reason } : {}) }); + } +} + export async function api(path: string, init?: RequestInit): Promise { const r = await fetch(withBase(path), { headers: { "content-type": "application/json" }, ...init }); const text = await r.text(); @@ -459,7 +467,7 @@ export async function api(path: string, init?: RequestInit): Promis swallow("web-ui: parse api response body", e); } if (!r.ok) { - if (r.status === 401 && path !== "/signin") reportSigninRequired(body as SigninRequired); + if (path !== "/signin") reportAccessGate(r.status, body); const msg = (body as { error?: string; message?: string })?.message ?? (body as { error?: string })?.error ?? diff --git a/plugins/web-ui/src/files.ts b/plugins/web-ui/src/files.ts index 2fc996b90..548ee422d 100644 --- a/plugins/web-ui/src/files.ts +++ b/plugins/web-ui/src/files.ts @@ -1,6 +1,6 @@ import { html, nothing, render } from "lit"; import { File, Image, Upload } from "lucide"; -import { api, reportSigninRequired, type SigninRequired, withBase } from "./core-bridge"; +import { api, reportAccessGate, withBase } from "./core-bridge"; import { errMessage } from "../../chassis/src/errors"; import { browserRenderableImage, fieldSelect, formatBytes, icon, relTime } from "./ui"; import { contextsState, ensureContexts, personalScopeId, scopeChip, scopeFilterControl } from "./contexts"; @@ -223,8 +223,8 @@ async function uploadOne(file: globalThis.File): Promise { const text = await r.text(); let message = `Upload failed (${r.status})`; try { - const parsed = JSON.parse(text) as { message?: string; error?: string } & SigninRequired; - if (r.status === 401) reportSigninRequired(parsed); + const parsed = JSON.parse(text) as { message?: string; error?: string }; + reportAccessGate(r.status, parsed); message = parsed.message ?? parsed.error ?? message; } catch { if (text.trim()) message = text.trim(); diff --git a/plugins/web-ui/src/shell.ts b/plugins/web-ui/src/shell.ts index 1f29ae2b8..3cc418257 100644 --- a/plugins/web-ui/src/shell.ts +++ b/plugins/web-ui/src/shell.ts @@ -325,6 +325,16 @@ function deniedGate() { `); } +function deactivatedGate() { + return gateShell(html` +

Your account is deactivated

+ + + `); +} + function retryBoot(): void { void bootSafely(); } @@ -390,6 +400,7 @@ function devGate(gate: { value?: string; error?: string; pending?: boolean }) { export type AuthGate = | { kind: "portal" } | { kind: "denied" } + | { kind: "deactivated" } | { kind: "unreachable" } | { kind: "dev"; value?: string; error?: string; pending?: boolean }; @@ -401,6 +412,8 @@ export function renderAuthGate(gate: AuthGate): void { return portalGate(); case "denied": return deniedGate(); + case "deactivated": + return deactivatedGate(); case "unreachable": return unreachableGate(); default: @@ -410,7 +423,11 @@ export function renderAuthGate(gate: AuthGate): void { render(body, appEl as HTMLElement); } -function gateFor(mode: AuthMode, reason: "unauthenticated" | "not_allowed" | undefined): AuthGate { +function gateFor( + mode: AuthMode, + reason: "unauthenticated" | "not_allowed" | "account_deactivated" | undefined, +): AuthGate { + if (reason === "account_deactivated") return { kind: "deactivated" }; if (reason === "not_allowed") return { kind: "denied" }; return mode === "dev" ? { kind: "dev" } : { kind: "portal" }; } @@ -805,8 +822,12 @@ export async function boot(): Promise { renderAuthGate({ kind: "unreachable" }); return; } - if (r.status === 401) { + if (r.status === 401 || r.status === 403) { const body = (await r.json().catch(() => ({}))) as SigninRequired; + if (r.status === 403 && body.reason !== "account_deactivated") { + renderAuthGate({ kind: "unreachable" }); + return; + } authMode = body.mode ?? "portal"; renderAuthGate(gateFor(authMode, body.reason)); return; diff --git a/plugins/web-ui/test/account-deactivated-source.test.ts b/plugins/web-ui/test/account-deactivated-source.test.ts new file mode 100644 index 000000000..0e8f0e0c0 --- /dev/null +++ b/plugins/web-ui/test/account-deactivated-source.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { reportAccessGate, setSigninRequiredHandler, type SigninRequired } from "../src/core-bridge.ts"; + +const shell = readFileSync(new URL("../src/shell.ts", import.meta.url), "utf8"); +const bridge = readFileSync(new URL("../src/core-bridge.ts", import.meta.url), "utf8"); + +test("the web UI distinguishes account deactivation from session expiry", () => { + assert.match(shell, /Your account is deactivated/); + assert.match(shell, /portal session is valid/); + assert.match(shell, /reason === "account_deactivated"/); + assert.match(bridge, /status === 403/); + assert.match(bridge, /detail\.error === "account_deactivated"/); +}); + +test("an error-only deactivation response is normalized before rendering", () => { + let received: SigninRequired | null = null; + setSigninRequiredHandler((detail) => { + received = detail; + }); + reportAccessGate(403, { error: "account_deactivated" }); + assert.deepEqual(received, { error: "account_deactivated", reason: "account_deactivated" }); +}); diff --git a/plugins/web-ui/test/auth-mode-portal.test.ts b/plugins/web-ui/test/auth-mode-portal.test.ts index 1becd15a5..8ac7009ba 100644 --- a/plugins/web-ui/test/auth-mode-portal.test.ts +++ b/plugins/web-ui/test/auth-mode-portal.test.ts @@ -4,7 +4,19 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { mintPortalIdentity, PORTAL_IDENTITY_HEADER } from "../../chassis/src/portal-identity.ts"; -const core = createServer((_req, res) => { +let accountDeactivated = false; +const core = createServer((req, res) => { + if (accountDeactivated && req.url?.startsWith("/v1/session-cap")) { + res.writeHead(403, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: "account_deactivated", + reason: "account_deactivated", + message: "This account is deactivated. Ask an administrator to reactivate it.", + }), + ); + return; + } res.writeHead(200, { "content-type": "application/json" }); res.end("{}"); }); @@ -65,3 +77,15 @@ test("a verified allowed principal gets through and /me reports the mode", async assert.equal(body.user, "alice"); assert.equal(body.mode, "portal"); }); + +test("a valid portal session relays the core's explicit account-deactivated state", async () => { + accountDeactivated = true; + try { + const token = mintPortalIdentity({ p: "alice", exp: Date.now() + 60_000 }, SECRET); + const r = await fetch(`${base}/me`, { headers: { [PORTAL_IDENTITY_HEADER]: token } }); + assert.equal(r.status, 403); + assert.equal((await r.json()).reason, "account_deactivated"); + } finally { + accountDeactivated = false; + } +}); diff --git a/plugins/web-ui/test/blob-upload-server-route.test.ts b/plugins/web-ui/test/blob-upload-server-route.test.ts index 0b5f35521..867b782e4 100644 --- a/plugins/web-ui/test/blob-upload-server-route.test.ts +++ b/plugins/web-ui/test/blob-upload-server-route.test.ts @@ -17,12 +17,24 @@ type TurnBody = { model?: string; }; let lastTurnBody: TurnBody | null = null; +let accountDeactivated = false; const setTurnBody = (b: TurnBody | null): void => { lastTurnBody = b; }; const core = createServer((req: IncomingMessage, res) => { const u = req.url ?? ""; + if (req.method === "POST" && u.startsWith("/v1/session-cap")) { + req.resume(); + res.writeHead(accountDeactivated ? 403 : 200, { "content-type": "application/json" }); + return void res.end( + JSON.stringify( + accountDeactivated + ? { error: "account_deactivated", reason: "account_deactivated" } + : { token: "upload-capability" }, + ), + ); + } if (req.method === "POST" && u.startsWith("/v1/blobs")) { const sha = String(req.headers["x-content-sha256"] ?? ""); let size = 0; @@ -56,6 +68,7 @@ const SECRET = "blob-route-test-secret"; process.env.CORE_API_URL = coreUrl; process.env.CORE_SIGNING_SECRET = SECRET; process.env.WEB_UI_PRINCIPALS = "alice"; +process.env.ALLOW_UNSIGNED_TEST_IDENTITY = "0"; const { handler } = await import("../server/index.ts"); @@ -115,6 +128,24 @@ test("POST /api/blobs requires a signed-in user (401 before any core call)", asy assert.equal(blobUploads.length, before, "no upload forwarded for an unauthenticated request"); }); +test("POST /api/blobs rejects a deactivated account before staging bytes", async () => { + const before = blobUploads.length; + const sha = createHash("sha256").update("blocked").digest("hex"); + accountDeactivated = true; + try { + const r = await fetch(`${base}/api/blobs?sha=${sha}`, { + method: "POST", + headers: { ...IDENTITY, "content-type": "application/octet-stream" }, + body: Buffer.from("blocked"), + }); + assert.equal(r.status, 403); + assert.equal(((await r.json()) as { reason?: string }).reason, "account_deactivated"); + assert.equal(blobUploads.length, before); + } finally { + accountDeactivated = false; + } +}); + test("the sha rides as a query param (portal-safe): a custom header is NOT how the surface reads it", async () => { const before = blobUploads.length; const sha = createHash("sha256").update("y").digest("hex"); diff --git a/plugins/web-ui/test/session-cap-replay.test.ts b/plugins/web-ui/test/session-cap-replay.test.ts index 34a5e152e..30169a7ec 100644 --- a/plugins/web-ui/test/session-cap-replay.test.ts +++ b/plugins/web-ui/test/session-cap-replay.test.ts @@ -81,6 +81,6 @@ test("same-second session-cap mints stay unique past core's replay dedupe", asyn Date.now = realNow; } - assert.equal(sessionCapMints, 2); - assert.equal(seenSignatures.size, 2, "each session-cap mint must carry a distinct signature"); + assert.equal(sessionCapMints, 3); + assert.equal(seenSignatures.size, 3, "each session-cap mint must carry a distinct signature"); }); diff --git a/src/admin/users.ts b/src/admin/users.ts index 236bb2676..b2c015f3a 100644 --- a/src/admin/users.ts +++ b/src/admin/users.ts @@ -12,6 +12,7 @@ export interface AdminUserRow { export interface UsersInput extends Omit { grants: readonly AdminGrant[]; + principalIds?: readonly string[]; } export function computeUsers(input: UsersInput): AdminUserRow[] { @@ -37,7 +38,11 @@ export function computeUsers(input: UsersInput): AdminUserRow[] { }, }); - const ids = new Set([...sessionsByUser.keys(), ...grants.map((g) => g.principalId)]); + const ids = new Set([ + ...sessionsByUser.keys(), + ...grants.map((g) => g.principalId), + ...(input.principalIds ?? []), + ]); return [...ids] .map((principalId) => ({ principalId, diff --git a/src/api/app-messaging.ts b/src/api/app-messaging.ts index 766f01241..6cb414630 100644 --- a/src/api/app-messaging.ts +++ b/src/api/app-messaging.ts @@ -310,31 +310,65 @@ export function createMessagingMethods( }, async upsertDirectory(members, syncedAt) { - const previous = await deps.directory.list(); - if (!(await deps.directory.replace(members, syncedAt))) return; - const present = members.filter((m) => m.type === "internal").map((m) => m.principalId); - const presentSet = new Set(present); - const removed = previous.map((m) => m.principalId).filter((id) => !presentSet.has(id)); - const outcome = await deps.identity.recordDirectorySync(removed, present); - const orgScope = scopeId("org", orgIdOf()); - for (const id of outcome.deactivated) { - deps.auditLog.record({ - at: Date.now(), - principalId: id, - action: "principal.deactivate", - resource: "directory-sync", - scopeLabel: orgScope, - }); - } - for (const id of outcome.reactivated) { - deps.auditLog.record({ - at: Date.now(), - principalId: id, - action: "principal.reactivate", - resource: "directory-sync", - scopeLabel: orgScope, + const sync = async () => { + const previous = await deps.directory.list(); + const previousByKey = new Map(previous.map((member) => [personKey(member.principalId), member])); + const incomingByKey = new Map( + members + .filter((member) => member.principalId && member.type === "internal") + .map((member) => [personKey(member.principalId), member]), + ); + const next = [...incomingByKey].map(([key, member]) => { + const prior = previousByKey.get(key); + const identitySource = + !prior || prior.identitySource === "directory-sync" ? ("directory-sync" as const) : undefined; + return { + principalId: prior?.principalId ?? member.principalId, + displayName: member.displayName, + type: member.type, + ...(member.slackId ? { slackId: member.slackId } : {}), + ...(identitySource ? { identitySource } : {}), + }; }); - } + for (const member of previous) { + if (member.identitySource !== "directory-sync" && !incomingByKey.has(personKey(member.principalId))) { + next.push(member); + } + } + if (!(await deps.directory.replace(next, syncedAt))) return; + const present = next + .filter( + (member) => member.identitySource === "directory-sync" && incomingByKey.has(personKey(member.principalId)), + ) + .map((member) => member.principalId); + const presentSet = new Set(present.map(personKey)); + const removed = previous + .filter((member) => member.identitySource === "directory-sync") + .map((member) => member.principalId) + .filter((id) => !presentSet.has(personKey(id))); + const outcome = await deps.identity.recordDirectorySync(removed, present); + const orgScope = scopeId("org", orgIdOf()); + for (const id of outcome.deactivated) { + deps.auditLog.record({ + at: Date.now(), + principalId: id, + action: "principal.deactivate", + resource: "directory-sync", + scopeLabel: orgScope, + }); + } + for (const id of outcome.reactivated) { + deps.auditLog.record({ + at: Date.now(), + principalId: id, + action: "principal.reactivate", + resource: "directory-sync", + scopeLabel: orgScope, + }); + } + }; + if (deps.advisoryLock) await deps.advisoryLock.withLock(`directory-members:${orgIdOf()}`, sync); + else await sync(); }, async upsertChannels(channels, channelMembers, syncedAt) { await deps.directory.replaceChannels(channels, channelMembers, syncedAt); diff --git a/src/api/routes/admin.ts b/src/api/routes/admin.ts index 4bf8c7d29..8821b7a2b 100644 --- a/src/api/routes/admin.ts +++ b/src/api/routes/admin.ts @@ -18,6 +18,7 @@ import { getUserDetail, listKeychainStatus, listUsers, + reactivateUser, resetUserToBrandNew, revokeAdminGrant, searchDirectory, @@ -105,6 +106,7 @@ const routes: ReadonlyArray> = [ { method: "GET", path: "/v1/admin/users/:principalId", auth: "either", handle: getUserDetail }, { method: "PUT", path: "/v1/admin/users/:principalId/onboarding", auth: "either", handle: setUserOnboarding }, { method: "POST", path: "/v1/admin/users/:principalId/reset", auth: "either", handle: resetUserToBrandNew }, + { method: "POST", path: "/v1/admin/users/:principalId/reactivate", auth: "either", handle: reactivateUser }, { method: "POST", path: "/v1/admin/grants", auth: "either", handle: createAdminGrant }, { method: "DELETE", path: "/v1/admin/grants/:principalId", auth: "either", handle: revokeAdminGrant }, { method: "POST", path: "/v1/admin/impersonate/stop", auth: "either", handle: stopImpersonation }, diff --git a/src/api/routes/admin/scope-config.ts b/src/api/routes/admin/scope-config.ts index 22acc4f7e..17d206168 100644 --- a/src/api/routes/admin/scope-config.ts +++ b/src/api/routes/admin/scope-config.ts @@ -132,10 +132,25 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const crons = await app.listCrons(); const deployments = await app.listDeployments(); const skills = await app.listSkills(); + const environmentRows = await app.listEnvironments(); + const environments = environmentRows.map(({ environment, attachments }) => ({ + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + attachedScopes: attachments.map((attachment) => attachment.scopeId).sort(), + })); + const environmentById = new Map(environments.map((environment) => [environment.id, environment])); + const attachmentByScope = new Map( + environments.flatMap((environment) => + environment.attachedScopes.map((attachedScope) => [attachedScope, environment] as const), + ), + ); const owners = [ ...crons.map((c) => c.ownerScopeId), ...deployments.map((d) => d.ownerScopeId), ...skills.map((s) => s.scopeId), + ...environments.map((environment) => environment.id), + ...environments.flatMap((environment) => environment.attachedScopes), ]; const labels = await discoverScopes(app, deps, owners); const countBy = (ids: string[]): Map => { @@ -171,18 +186,31 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const cronN = countBy(crons.map((c) => c.ownerScopeId)); const deployN = countBy(deployments.map((d) => d.ownerScopeId)); const skillN = countBy(skills.map((s) => s.scopeId)); - const scopes = [...labels].map(([id, label]) => ({ - scopeId: id, - ...(label ? { label } : {}), - sessions: sessionN.get(id) ?? 0, - backgroundSessions: backgroundN.get(id) ?? 0, - lastActivity: lastActivityBy.get(id) ?? 0, - lastConversationActivity: lastConversationBy.get(id) ?? 0, - lastMessage: lastMessageBy.get(id) ?? "", - crons: cronN.get(id) ?? 0, - deployments: deployN.get(id) ?? 0, - skills: skillN.get(id) ?? 0, - })); + const scopes = [...labels].map(([id, label]) => { + const environment = environmentById.get(id); + const attachment = attachmentByScope.get(id); + return { + scopeId: id, + ...(label ? { label } : {}), + ...(environment?.name ? { environmentName: environment.name } : {}), + ...(attachment + ? { + environmentAttachment: { + environmentId: attachment.id, + environmentName: attachment.name, + }, + } + : {}), + sessions: sessionN.get(id) ?? 0, + backgroundSessions: backgroundN.get(id) ?? 0, + lastActivity: lastActivityBy.get(id) ?? 0, + lastConversationActivity: lastConversationBy.get(id) ?? 0, + lastMessage: lastMessageBy.get(id) ?? "", + crons: cronN.get(id) ?? 0, + deployments: deployN.get(id) ?? 0, + skills: skillN.get(id) ?? 0, + }; + }); scopes.sort( (a, b) => b.lastActivity - a.lastActivity || @@ -190,7 +218,35 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { b.backgroundSessions - a.backgroundSessions || a.scopeId.localeCompare(b.scopeId), ); - return sendJson(res, 200, { scopeId: scope, scopes }); + return sendJson(res, 200, { scopeId: scope, scopes, environments }); +} + +interface ScopeEnvironmentMetadata { + environment?: { id: string; name: string; ownerActorId: string | null }; + environmentAttachment?: { environmentId: string; environmentName: string | null }; +} + +async function scopeEnvironmentMetadata(deps: ApiCtx["deps"], targetScope: string): Promise { + const store = deps.environments; + if (!store) return {}; + + const [environment, attachment] = await Promise.all([store.get(targetScope), store.getAttachment(targetScope)]); + const metadata: ScopeEnvironmentMetadata = {}; + if (environment?.name) { + metadata.environment = { + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + }; + } + if (attachment) { + const attachedEnvironment = await store.get(attachment.environmentId); + metadata.environmentAttachment = { + environmentId: attachment.environmentId, + environmentName: attachedEnvironment?.name ?? null, + }; + } + return metadata; } export async function getScopeConfig(ctx: ApiCtx): Promise { @@ -202,6 +258,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { if (!actor) return; await deps.config.refreshScope(targetScope); audit(deps, { principalId: actor.id, action: "config.read", resource: "config", scopeLabel: targetScope }); + const environmentMetadata = await scopeEnvironmentMetadata(deps, targetScope); const serviceCredentials = await Promise.all( (deps.serviceCreds ? await deps.serviceCreds.listServiceCredentials(targetScope) : []).map(async (c) => { const usage = (await deps.credentialUsage?.list({ slug: c.slug, limit: 5000 })) ?? []; @@ -261,6 +318,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { }; return sendJson(res, 200, { scopeId: targetScope, + ...environmentMetadata, ...values, soulVersion: deps.config.soulVersion(targetScope), soulHistory: deps.config.soulHistory(targetScope), diff --git a/src/api/routes/admin/users.ts b/src/api/routes/admin/users.ts index c629b2379..b9367bb14 100644 --- a/src/api/routes/admin/users.ts +++ b/src/api/routes/admin/users.ts @@ -24,7 +24,15 @@ export async function listUsers(ctx: ApiCtx): Promise { const participants = (await deps.sessions?.listParticipants()) ?? []; const turns = (await deps.sessions?.attributedTurns()) ?? []; const grants = (await deps.admin?.listGrants()) ?? []; - const users = computeUsers({ participants, turns, grants }); + await deps.identity?.refresh(); + const deactivations = deps.identity?.deactivations() ?? []; + const deactivationByPrincipal = new Map(deactivations.map((record) => [personKey(record.principalId), record])); + const users = computeUsers({ + participants, + turns, + grants, + principalIds: deactivations.map((record) => record.principalId), + }).map((user) => ({ ...user, deactivation: deactivationByPrincipal.get(personKey(user.principalId)) ?? null })); return sendJson(res, 200, { scopeId: scope, users, grants }); } @@ -105,6 +113,8 @@ export async function getUserDetail(ctx: ApiCtx): Promise { const grants = (await deps.admin?.listGrants()) ?? []; const member = await app.directoryMember(principalId); + await deps.identity?.refresh(); + const deactivation = deps.identity?.deactivation(principalId) ?? null; const participants = (await deps.sessions?.listParticipants()) ?? []; const attributed = (await deps.sessions?.attributedTurns()) ?? []; @@ -230,6 +240,7 @@ export async function getUserDetail(ctx: ApiCtx): Promise { scopeId: personal, ...(member?.displayName ? { displayName: member.displayName } : {}), admin: adminStatusFromGrants(grants, principalId), + deactivation, stats: { sessions: mySessionIds.size, turns, firstSeenAt, lastSeenAt }, conversations, files, @@ -240,6 +251,23 @@ export async function getUserDetail(ctx: ApiCtx): Promise { }); } +export async function reactivateUser(ctx: ApiCtx): Promise { + const { res, deps, params } = ctx; + const scope = orgScope(deps); + const actor = await authorizeAdmin(ctx, scope); + if (!actor) return; + if (!deps.identity) return sendJson(res, 404, { error: "not_found" }); + const principalId = params.principalId!; + await deps.identity.reactivate(principalId); + audit(deps, { + principalId: actor.id, + action: "principal.reactivate", + resource: principalId, + scopeLabel: scope, + }); + return sendJson(res, 200, { ok: true, principalId, active: true }); +} + export async function startImpersonation(ctx: ApiCtx): Promise { const { res, app, deps, body } = ctx; const org = orgScope(deps); diff --git a/src/api/routes/directory.ts b/src/api/routes/directory.ts index d71660a38..a467334f2 100644 --- a/src/api/routes/directory.ts +++ b/src/api/routes/directory.ts @@ -114,7 +114,12 @@ async function resolveDirectory(ctx: ApiCtx): Promise { else if (r.kind === "ambiguous") found = r.candidates; const matches = found.map((m) => { const slackId = m.slackId ?? (SLACK_ID_RE.test(m.principalId) ? m.principalId : undefined); - return slackId ? { ...m, slackId } : m; + return { + principalId: m.principalId, + displayName: m.displayName, + type: m.type, + ...(slackId ? { slackId } : {}), + }; }); return sendJson(res, 200, { matches }); } diff --git a/src/api/server.ts b/src/api/server.ts index 8b82a11fb..6ea306858 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -21,6 +21,8 @@ import { verifyPortalIdentity, PORTAL_IDENTITY_HEADER, type PortalIdentity } fro import { isUserScoped, userScopedField, assertedActor, isUnclassifiedWrite } from "./user-scoped-routes.ts"; import { errMessage } from "../util/errors.ts"; import { parseScopeId } from "../types.ts"; +import { orgScope as configOrgScope } from "../config.ts"; +import type { DeactivationRecord } from "../identity/identity-service.ts"; import { canonicalPayload, PayloadTooLargeError, readRawBody, sendJson, verifyOrReject } from "./http.ts"; import { dispatch, findRoute, run, type ApiCtx, type BaseCtx, type Route, type RouteAuth } from "./routes/route.ts"; import { apiRoutes, rawRoutes } from "./routes/index.ts"; @@ -47,6 +49,9 @@ function capabilityAdminDenied(method: string, pathname: string, url: URL, claim if (pathname.startsWith("/v1/admin/impersonate")) { return "impersonating a user is portal-only — the agent cannot act as another person"; } + if (/^\/v1\/admin\/users\/[^/]+\/reactivate$/.test(pathname)) { + return "reactivating a user is portal-only — the agent cannot restore account access"; + } if ( method === "PUT" && /^\/v1\/admin\/scopes\/[^/]+\/import$/.test(pathname) && @@ -248,6 +253,7 @@ async function gate( } let actor: PortalIdentity | null = null; + let actorDeactivation: DeactivationRecord | null = null; if (!capability) { const psecret = deps.portalIdentitySecret ?? secret; const rawToken = req.headers[PORTAL_IDENTITY_HEADER]; @@ -255,33 +261,52 @@ async function gate( actor = token && psecret ? await verifyPortalIdentity(token, psecret, Date.now()) : null; if (actor && deps.identity) { await deps.identity.refresh(); - if (deps.identity.classify(actor.p).type !== "internal") actor = null; + const access = await deps.identity.portalIdentityAccess(actor.p, !actor.imp); + if (!access.active) { + actorDeactivation = access.deactivation; + actor = null; + } else if (access.recovered) { + deps.auditLog?.record({ + at: Date.now(), + principalId: actor.p, + action: "principal.reactivate", + resource: "portal-identity-recovery", + scopeLabel: configOrgScope(), + }); + } } - if (!isPublicRoute && requirePortalIdentity) { - const webTurn = - method === "POST" && - pathname === "/v1/turns" && - body !== null && - typeof body === "object" && - (body as { surface?: unknown }).surface === "web"; - const needsActor = - isUserScoped(method, pathname) || - webTurn || - pathname.startsWith("/v1/admin/") || - isUnclassifiedWrite(method, pathname); - if (needsActor) { - if (!psecret || !actor) { - sendJson(res, 401, { error: "unauthorized", message: "portal identity required" }); - return null; - } - const field = webTurn ? undefined : userScopedField(method, pathname); - let asserted: unknown = null; - if (webTurn) asserted = (body as { actor?: { externalId?: unknown } }).actor?.externalId ?? null; - else if (field) asserted = assertedActor(field, url, body, req); - if ((field && asserted !== actor.p) || (!field && asserted !== null && asserted !== actor.p)) { - sendJson(res, 403, { error: "forbidden", message: "portal identity does not match the requested actor" }); - return null; - } + const webTurn = + method === "POST" && + pathname === "/v1/turns" && + body !== null && + typeof body === "object" && + (body as { surface?: unknown }).surface === "web"; + const needsActor = + isUserScoped(method, pathname) || + webTurn || + pathname.startsWith("/v1/admin/") || + isUnclassifiedWrite(method, pathname); + if (!isPublicRoute && needsActor && actorDeactivation) { + sendJson(res, 403, { + error: "account_deactivated", + message: "This account is deactivated. Ask an administrator to reactivate it.", + reason: "account_deactivated", + source: actorDeactivation.source, + }); + return null; + } + if (!isPublicRoute && requirePortalIdentity && needsActor) { + if (!psecret || !actor) { + sendJson(res, 401, { error: "unauthorized", message: "portal identity required" }); + return null; + } + const field = webTurn ? undefined : userScopedField(method, pathname); + let asserted: unknown = null; + if (webTurn) asserted = (body as { actor?: { externalId?: unknown } }).actor?.externalId ?? null; + else if (field) asserted = assertedActor(field, url, body, req); + if ((field && asserted !== actor.p) || (!field && asserted !== null && asserted !== actor.p)) { + sendJson(res, 403, { error: "forbidden", message: "portal identity does not match the requested actor" }); + return null; } } } diff --git a/src/api/user-scoped-routes.ts b/src/api/user-scoped-routes.ts index 2cc5fc156..09ff5b069 100644 --- a/src/api/user-scoped-routes.ts +++ b/src/api/user-scoped-routes.ts @@ -72,6 +72,7 @@ const USER_SCOPED: Rule[] = [ pat("GET", "/v1/runs/:id"), pat("GET", "/v1/runs"), pat("POST", "/v1/runs/:id/signal"), + pat("POST", "/v1/session-cap"), ]; const SYSTEM: Rule[] = [ @@ -92,7 +93,6 @@ const SYSTEM: Rule[] = [ pat("POST", "/v1/egress-audit"), pat("POST", "/v1/auth/broker/claim"), pat("PUT", "/v1/deployment-layer"), - pat("POST", "/v1/session-cap"), pat("POST", "/v1/keychain/drops/:id"), pat("POST", "/v1/keychain/asks"), pat("POST", "/v1/keychain/asks/:id/decline"), diff --git a/src/directory/directory-store.ts b/src/directory/directory-store.ts index b3f09fbfe..501a2a118 100644 --- a/src/directory/directory-store.ts +++ b/src/directory/directory-store.ts @@ -6,6 +6,7 @@ export interface DirectoryMember { displayName: string; type: PrincipalType; slackId?: string; + identitySource?: "directory-sync"; } export interface DirectoryChannel { diff --git a/src/directory/postgres-directory-store.ts b/src/directory/postgres-directory-store.ts index 9aae41882..daa331364 100644 --- a/src/directory/postgres-directory-store.ts +++ b/src/directory/postgres-directory-store.ts @@ -21,6 +21,7 @@ const SCHEMA = [ display_name_lc TEXT NOT NULL, type TEXT NOT NULL, slack_id TEXT, + identity_source TEXT, PRIMARY KEY (org_id, principal_id) )`, `CREATE INDEX IF NOT EXISTS directory_members_name @@ -36,6 +37,32 @@ const SCHEMA = [ ALTER TABLE directory_members ADD COLUMN slack_id TEXT; END IF; END $$`, + `DO $$ + BEGIN + CREATE TABLE IF NOT EXISTS directory_member_ownership( + org_id TEXT NOT NULL, + principal_key TEXT NOT NULL, + source TEXT NOT NULL, + PRIMARY KEY (org_id, principal_key) + ); + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'directory_members' AND column_name = 'identity_source' + ) THEN + ALTER TABLE directory_members ADD COLUMN identity_source TEXT; + END IF; + LOCK TABLE directory_members IN SHARE MODE; + INSERT INTO directory_member_ownership (org_id, principal_key, source) + SELECT org_id, + CASE WHEN position('@' in principal_id) > 0 THEN lower(principal_id) ELSE principal_id END, + CASE + WHEN identity_source = 'directory-sync' OR slack_id IS NOT NULL OR principal_id ~ '^[UW][A-Z0-9]+$' + THEN 'directory-sync' + ELSE 'portal' + END + FROM directory_members + ON CONFLICT (org_id, principal_key) DO NOTHING; + END $$`, `CREATE TABLE IF NOT EXISTS directory_channels( org_id TEXT NOT NULL, channel_id TEXT NOT NULL, @@ -96,14 +123,28 @@ const SCHEMA = [ ]; function memberRow(r: Record): DirectoryMember { + const principalId = r.principal_id as string; + const identitySource = + r.ownership_source === "directory-sync" || + (r.ownership_source !== "portal" && + (r.identity_source === "directory-sync" || r.slack_id || /^[UW][A-Z0-9]+$/.test(principalId))) + ? "directory-sync" + : undefined; return { - principalId: r.principal_id as string, + principalId, displayName: r.display_name as string, type: r.type as PrincipalType, ...(r.slack_id ? { slackId: r.slack_id as string } : {}), + ...(identitySource ? { identitySource } : {}), }; } -const MEMBER_COLS = "principal_id, display_name, type, slack_id"; +const MEMBER_COLS = `principal_id, display_name, type, slack_id, identity_source, + (SELECT source FROM directory_member_ownership ownership + WHERE ownership.org_id = directory_members.org_id + AND ownership.principal_key = CASE + WHEN position('@' in directory_members.principal_id) > 0 THEN lower(directory_members.principal_id) + ELSE directory_members.principal_id + END) AS ownership_source`; function channelRow(r: Record): DirectoryChannel { return { channelId: r.channel_id as string, name: r.name as string, isPrivate: r.is_private as boolean }; } @@ -225,17 +266,19 @@ export function createPostgresDirectoryStore(connectionString: string): Director return { async replace(members, syncedAt) { const byId = new Map(); - for (const m of members) if (m.principalId && m.type === "internal") byId.set(m.principalId, m); + for (const m of members) if (m.principalId && m.type === "internal") byId.set(personKey(m.principalId), m); const internal = [...byId.values()]; - const hash = hashRoster(internal.map((m) => `${m.principalId}|${m.displayName}|${m.type}|${m.slackId ?? ""}`)); + const hash = hashRoster( + internal.map((m) => `${m.principalId}|${m.displayName}|${m.type}|${m.slackId ?? ""}|${m.identitySource ?? ""}`), + ); return swapIfChanged("members_hash", hash, syncedAt, async (client) => { await client.query("DELETE FROM directory_members WHERE org_id = $1", [orgId]); if (internal.length) { await client.query( - `INSERT INTO directory_members (org_id, principal_id, display_name, display_name_lc, type, slack_id) - SELECT $1, * FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[])`, + `INSERT INTO directory_members (org_id, principal_id, display_name, display_name_lc, type, slack_id, identity_source) + SELECT $1, * FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])`, [ orgId, internal.map((m) => m.principalId), @@ -243,8 +286,18 @@ export function createPostgresDirectoryStore(connectionString: string): Director internal.map((m) => normDirectoryQuery(m.displayName)), internal.map((m) => m.type), internal.map((m) => m.slackId ?? null), + internal.map((m) => m.identitySource ?? null), ], ); + await client.query( + `INSERT INTO directory_member_ownership (org_id, principal_key, source) + SELECT $1, * FROM unnest($2::text[], $3::text[]) + ON CONFLICT (org_id, principal_key) DO UPDATE SET source = CASE + WHEN directory_member_ownership.source = 'portal' THEN 'portal' + ELSE EXCLUDED.source + END`, + [orgId, internal.map((m) => personKey(m.principalId)), internal.map((m) => m.identitySource ?? "portal")], + ); } }); }, diff --git a/src/identity/identity-service.ts b/src/identity/identity-service.ts index bcf3f479d..41263b725 100644 --- a/src/identity/identity-service.ts +++ b/src/identity/identity-service.ts @@ -13,8 +13,12 @@ export interface DeactivationRecord { principalId: string; source: DeactivationSource; at: number; + identitySource?: "directory-sync"; } +export type PortalIdentityAccess = + { active: true; recovered: boolean } | { active: false; deactivation: DeactivationRecord }; + interface DirectorySyncOutcome { deactivated: string[]; reactivated: string[]; @@ -23,9 +27,12 @@ interface DirectorySyncOutcome { export interface IdentityService extends IdentityProvider { isInternal(p: Principal): boolean; audienceIsAllInternal(audience: Principal[]): boolean; - deactivate(externalId: string, source?: DeactivationSource): Promise; + deactivate(externalId: string, source?: DeactivationSource, identitySource?: "directory-sync"): Promise; reactivate(externalId: string): Promise; recordDirectorySync(removedIds: string[], presentIds: string[]): Promise; + portalIdentityAccess(externalId: string, recoverLegacy?: boolean): Promise; + deactivation(externalId: string): DeactivationRecord | null; + deactivations(): DeactivationRecord[]; hydrate(): Promise; refresh(): Promise; } @@ -43,13 +50,53 @@ export function createIdentityService(backing?: DurableMap): return { id: externalId, type }; } - async function deactivate(externalId: string, source: DeactivationSource = "manual"): Promise { + const atomicUpdate = store.update; + const atomicDelete = store.deleteIf; + + async function deactivateRecord( + externalId: string, + source: DeactivationSource = "manual", + identitySource?: "directory-sync", + ): Promise { const key = personKey(externalId); - const existing = deactivated.get(key); - if (existing && (existing.source === "manual" || existing.source === source)) return; - const record: DeactivationRecord = { principalId: externalId, source, at: Date.now() }; - deactivated.set(key, record); - await store.put(key, record); + const record: DeactivationRecord = { + principalId: externalId, + source, + at: Date.now(), + ...(identitySource ? { identitySource } : {}), + }; + if (!atomicUpdate) throw new Error("deactivation store does not support atomic updates"); + for (;;) { + const existing = await store.get(key); + if (!existing) { + const stored = await store.putIfAbsent(key, record); + deactivated.set(key, stored); + if (stored.at === record.at && stored.source === record.source) return true; + continue; + } + let changed = false; + const stored = await atomicUpdate.call(store, key, (current) => { + if (source === "directory-sync") { + if (current.source === "manual" || current.identitySource === "directory-sync") return current; + changed = true; + return { ...current, identitySource: "directory-sync" }; + } + if (current.source === "manual") return current; + changed = true; + return record; + }); + if (!stored) continue; + deactivated.set(key, stored); + return changed; + } + } + + async function deactivate( + externalId: string, + source: DeactivationSource = "manual", + identitySource?: "directory-sync", + ): Promise { + await deactivateRecord(externalId, source, identitySource); } async function reactivate(externalId: string): Promise { @@ -62,16 +109,52 @@ export function createIdentityService(backing?: DurableMap): classify, deactivate, reactivate, + deactivation(externalId) { + return deactivated.get(personKey(externalId)) ?? null; + }, + deactivations() { + return [...deactivated.values()].sort((a, b) => a.principalId.localeCompare(b.principalId)); + }, + async portalIdentityAccess(externalId, recoverLegacy = true) { + const key = personKey(externalId); + const record = await store.get(key); + if (record) deactivated.set(key, record); + else deactivated.delete(key); + if (!record) return { active: true, recovered: false }; + if (recoverLegacy && record.source === "directory-sync" && record.identitySource !== "directory-sync") { + if (!atomicDelete) throw new Error("deactivation store does not support conditional deletes"); + const recovered = await atomicDelete.call( + store, + key, + (current) => current.source === "directory-sync" && current.identitySource !== "directory-sync", + ); + if (recovered) { + deactivated.delete(key); + return { active: true, recovered: true }; + } + const current = await store.get(key); + if (!current) { + deactivated.delete(key); + return { active: true, recovered: false }; + } + deactivated.set(key, current); + return { active: false, deactivation: current }; + } + return { active: false, deactivation: record }; + }, async recordDirectorySync(removedIds: string[], presentIds: string[]): Promise { const outcome: DirectorySyncOutcome = { deactivated: [], reactivated: [] }; - for (const id of removedIds) { - if (deactivated.has(personKey(id))) continue; - await deactivate(id, "directory-sync"); - outcome.deactivated.push(id); + const removed = new Map(removedIds.map((id) => [personKey(id), id])); + const present = new Map(presentIds.map((id) => [personKey(id), id])); + for (const [key, id] of removed) { + if (present.has(key)) continue; + if (await deactivateRecord(id, "directory-sync", "directory-sync")) outcome.deactivated.push(id); } - for (const id of presentIds) { - if (deactivated.get(personKey(id))?.source !== "directory-sync") continue; - await reactivate(id); + for (const [key, id] of present) { + if (!atomicDelete) throw new Error("deactivation store does not support conditional deletes"); + const reactivated = await atomicDelete.call(store, key, (current) => current.source === "directory-sync"); + if (!reactivated) continue; + deactivated.delete(key); outcome.reactivated.push(id); } return outcome; diff --git a/test/admin-agent-capability.test.ts b/test/admin-agent-capability.test.ts index e791ca876..a14c93938 100644 --- a/test/admin-agent-capability.test.ts +++ b/test/admin-agent-capability.test.ts @@ -88,6 +88,22 @@ test("an org admin's capability token can read and rewrite a scope's notebook vi } }); +test("an admin capability cannot reactivate another account", async () => { + const s = start(); + try { + await s.built.identity.deactivate("member@example.com"); + const response = await fetch(`${s.base}/v1/admin/users/member%40example.com/reactivate`, { + method: "POST", + headers: { "x-agent-capability": await capFor("admin-alice") }, + }); + assert.equal(response.status, 403); + assert.match(((await response.json()) as { message: string }).message, /portal-only/); + assert.equal(s.built.identity.deactivation("member@example.com")?.source, "manual"); + } finally { + await s.close(); + } +}); + test("whoami answers an agent capability token for admins and non-admins alike", async () => { const s = start(); try { diff --git a/test/admin-scopes-directory.test.ts b/test/admin-scopes-directory.test.ts index dcb280e50..b604750af 100644 --- a/test/admin-scopes-directory.test.ts +++ b/test/admin-scopes-directory.test.ts @@ -16,6 +16,8 @@ function start() { admin: built.admin, auditLog: built.auditLog, sessions: built.sessions, + config: built.config, + environments: built.environments, }); server.listen(0); const base = `http://localhost:${(server.address() as AddressInfo).port}`; @@ -118,3 +120,34 @@ test("scopes without a session label fall back to the org directory (people's na await s.close(); } }); + +test("the admin scope directory exposes named environments and attached scopes", async () => { + const s = start(); + try { + await s.built.app.upsertChannels([ + { channelId: "A", name: "source" }, + { channelId: "B", name: "attached" }, + ]); + await s.built.app.createEnvironment({ scopeId: "channel:A", name: "A-permanent", actorId: "U1" }); + await s.built.app.attachScope({ scopeId: "channel:B", environmentId: "channel:A", actorId: "U1" }); + + const directory = await json(await fetch(`${s.base}/v1/admin/scopes`, { headers: ALICE_ADMIN })); + const byId = new Map(directory.scopes.map((row: any) => [row.scopeId, row])); + assert.deepEqual(directory.environments, [ + { id: "channel:A", name: "A-permanent", ownerActorId: "U1", attachedScopes: ["channel:B"] }, + ]); + assert.equal((byId.get("channel:A") as any).environmentName, "A-permanent"); + assert.deepEqual((byId.get("channel:B") as any).environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + + const attached = await json(await fetch(`${s.base}/v1/admin/scopes/channel:B`, { headers: ALICE_ADMIN })); + assert.deepEqual(attached.environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + } finally { + await s.close(); + } +}); diff --git a/test/admin-users.test.ts b/test/admin-users.test.ts index cc6df67b9..1f50cf5e9 100644 --- a/test/admin-users.test.ts +++ b/test/admin-users.test.ts @@ -66,6 +66,7 @@ function start() { sessions: built.sessions, memory: built.memory, auditLog: built.auditLog, + identity: built.identity, }); server.listen(0); const base = `http://localhost:${(server.address() as AddressInfo).port}`; @@ -303,3 +304,44 @@ test("/v1/admin/users: a freshly promoted user shows as admin in the roster", as await s.close(); } }); + +test("/v1/admin/users exposes deactivation state and an org admin can recover the account", async () => { + const s = start(); + try { + await s.built.identity.deactivate("locked@example.com", "directory-sync", "directory-sync"); + const headers = { "x-admin-actor": "admin-alice@default-org" }; + + const listed = (await (await fetch(`${s.base}/v1/admin/users`, { headers })).json()) as any; + const locked = listed.users.find((user: { principalId: string }) => user.principalId === "locked@example.com"); + assert.equal(locked.deactivation.source, "directory-sync"); + + const detail = (await ( + await fetch(`${s.base}/v1/admin/users/${encodeURIComponent("locked@example.com")}`, { headers }) + ).json()) as any; + assert.equal(detail.deactivation.identitySource, "directory-sync"); + + const denied = await fetch(`${s.base}/v1/admin/users/${encodeURIComponent("locked@example.com")}/reactivate`, { + method: "POST", + headers: { "x-admin-actor": "user-uma@default-org" }, + }); + assert.equal(denied.status, 403); + + const recovered = await fetch(`${s.base}/v1/admin/users/${encodeURIComponent("locked@example.com")}/reactivate`, { + method: "POST", + headers, + }); + assert.equal(recovered.status, 200); + assert.deepEqual(await recovered.json(), { ok: true, principalId: "locked@example.com", active: true }); + assert.equal(s.built.identity.deactivation("locked@example.com"), null); + assert.ok( + (await s.built.auditLog.events()).some( + (event) => + event.action === "principal.reactivate" && + event.principalId === "admin-alice" && + event.resource === "locked@example.com", + ), + ); + } finally { + await s.close(); + } +}); diff --git a/test/directory-store.test.ts b/test/directory-store.test.ts index d394a7aff..5920f446f 100644 --- a/test/directory-store.test.ts +++ b/test/directory-store.test.ts @@ -109,12 +109,19 @@ describe("member slackId (the real <@…> mention id for an email principal)", ( it("round-trips slackId through replace → get → list, and resolve", async () => { const d = createDirectoryStore(); await d.replace([ - { principalId: "eve@acme.com", displayName: "Eve", type: "internal", slackId: "U9" }, + { + principalId: "eve@acme.com", + displayName: "Eve", + type: "internal", + slackId: "U9", + identitySource: "directory-sync", + }, { principalId: "U5", displayName: "Dana", type: "internal" }, ]); assert.equal((await d.get("eve@acme.com"))?.slackId, "U9"); assert.equal((await d.get("U5"))?.slackId, undefined); assert.equal((await d.list()).find((m) => m.principalId === "eve@acme.com")?.slackId, "U9"); + assert.equal((await d.list()).find((m) => m.principalId === "eve@acme.com")?.identitySource, "directory-sync"); const r = await d.resolve("Eve"); assert.equal(r.kind === "one" && r.member.slackId, "U9"); }); diff --git a/test/identity-offboarding.test.ts b/test/identity-offboarding.test.ts index 5d1dd6ceb..58e7deede 100644 --- a/test/identity-offboarding.test.ts +++ b/test/identity-offboarding.test.ts @@ -68,6 +68,62 @@ describe("offboarding: directory sync and the /v1/principals routes drive deacti ); }); + it("a directory sync cannot deactivate a durable identity it did not create", async () => { + await built.directory.replace([ + { principalId: "portal@example.com", displayName: "Portal User", type: "internal" }, + { principalId: "U-slack", displayName: "Slack User", type: "internal", identitySource: "directory-sync" }, + ]); + + await built.app.upsertDirectory([]); + + assert.equal(built.identity.classify("portal@example.com").type, "internal"); + assert.equal(built.identity.deactivation("portal@example.com"), null); + assert.equal(built.identity.classify("U-slack").type, "guest"); + assert.equal(built.identity.deactivation("U-slack")?.identitySource, "directory-sync"); + await built.app.upsertDirectory([member("U-stay"), member("U-leave")]); + }); + + it("a portal-owned identity remains portal-owned after appearing in a Slack snapshot", async () => { + await built.directory.replace([ + { principalId: "Portal@Example.com", displayName: "Portal User", type: "internal" }, + ]); + + await built.app.upsertDirectory([ + { + principalId: "portal@example.com", + displayName: "Portal User from Slack", + type: "internal", + slackId: "U-portal", + }, + ]); + await built.app.upsertDirectory([]); + + assert.equal(built.identity.deactivation("portal@example.com"), null); + const stored = await built.directory.get("portal@example.com"); + assert.equal(stored?.identitySource, undefined); + assert.equal(stored?.displayName, "Portal User from Slack"); + }); + + it("a guest-shaped source row cannot erase or claim a portal-owned identity", async () => { + await built.directory.replace([ + { principalId: "portal-guest@example.com", displayName: "Portal Guest", type: "internal" }, + ]); + await built.app.upsertDirectory([ + { principalId: "portal-guest@example.com", displayName: "Slack Guest", type: "guest" }, + ]); + assert.equal(built.identity.deactivation("portal-guest@example.com"), null); + assert.equal((await built.directory.get("portal-guest@example.com"))?.identitySource, undefined); + }); + + it("newer roster state wins when concurrent sync calls finish in the opposite order", async () => { + await built.app.upsertDirectory([member("ordered@example.com")], 1000); + await Promise.all([ + built.app.upsertDirectory([member("ordered@example.com")], 3000), + built.app.upsertDirectory([], 2000), + ]); + assert.equal(built.identity.deactivation("ordered@example.com"), null); + }); + it("the deactivate/reactivate routes flip classification and are audited", async () => { const off = await signedPost("/v1/principals/U-manual/deactivate"); assert.equal(off.status, 200); @@ -95,6 +151,6 @@ describe("offboarding: directory sync and the /v1/principals routes drive deacti method: "POST", headers: { "content-type": "application/json", "x-agent-capability": cap }, }); - assert.equal(res.status, 401); + assert.equal(res.status, 403); }); }); diff --git a/test/identity.test.ts b/test/identity.test.ts index e2e195c0d..d710ecde0 100644 --- a/test/identity.test.ts +++ b/test/identity.test.ts @@ -98,6 +98,91 @@ test("a directory sync deactivates dropped members and self-heals when they reap assert.equal(svc.classify("U-gone").type, "internal"); }); +test("directory-sync deactivation records carry durable identity ownership", async () => { + const backing = createMemoryMap(); + const svc = createIdentityService(backing); + await svc.recordDirectorySync(["U-owned"], []); + assert.deepEqual(svc.deactivation("U-owned"), { + principalId: "U-owned", + source: "directory-sync", + identitySource: "directory-sync", + at: svc.deactivation("U-owned")!.at, + }); + const restored = createIdentityService(backing); + await restored.hydrate(); + assert.equal((await restored.portalIdentityAccess("U-owned")).active, false); +}); + +test("a verified portal identity repairs only legacy unowned directory-sync deactivations", async () => { + const backing = createMemoryMap(); + await backing.put("legacy@example.com", { + principalId: "Legacy@Example.com", + source: "directory-sync", + at: 1, + }); + const svc = createIdentityService(backing); + await svc.hydrate(); + assert.deepEqual(await svc.portalIdentityAccess("legacy@example.com"), { active: true, recovered: true }); + assert.equal(await backing.get("legacy@example.com"), null); + + await svc.deactivate("manual@example.com"); + assert.equal((await svc.portalIdentityAccess("manual@example.com")).active, false); +}); + +test("an impersonated portal identity cannot claim legacy recovery", async () => { + const svc = createIdentityService(); + await svc.deactivate("legacy@example.com", "directory-sync"); + const access = await svc.portalIdentityAccess("legacy@example.com", false); + assert.equal(access.active, false); + assert.equal(svc.deactivation("legacy@example.com")?.source, "directory-sync"); +}); + +test("stale instances cannot overwrite or recover a concurrent manual deactivation", async () => { + const backing = createMemoryMap(); + const stale = createIdentityService(backing); + const current = createIdentityService(backing); + await Promise.all([stale.hydrate(), current.hydrate()]); + + await current.deactivate("member@example.com"); + await stale.recordDirectorySync(["member@example.com"], []); + assert.equal((await backing.get("member@example.com"))?.source, "manual"); + assert.equal((await stale.portalIdentityAccess("member@example.com")).active, false); + assert.equal((await backing.get("member@example.com"))?.source, "manual"); +}); + +test("manual deactivation retries when a concurrent recovery deletes the prior record", async () => { + const backing = createMemoryMap(); + await backing.put("member@example.com", { + principalId: "member@example.com", + source: "directory-sync", + identitySource: "directory-sync", + at: 1, + }); + const update = backing.update!; + let raced = false; + const racing: typeof backing = { + ...backing, + async update(key, fn) { + if (!raced) { + raced = true; + await backing.delete(key); + } + return update.call(backing, key, fn); + }, + }; + const svc = createIdentityService(racing); + await svc.hydrate(); + await svc.deactivate("member@example.com"); + assert.equal((await backing.get("member@example.com"))?.source, "manual"); +}); + +test("directory synchronization folds email case and deduplicates transitions", async () => { + const svc = createIdentityService(); + const outcome = await svc.recordDirectorySync(["Member@Example.com", "member@example.com"], ["MEMBER@example.com"]); + assert.deepEqual(outcome, { deactivated: [], reactivated: [] }); + assert.equal(svc.deactivation("member@example.com"), null); +}); + test("a manual deactivation survives roster churn — only reactivate() clears it", async () => { const svc = createIdentityService(); await svc.deactivate("U-fired"); diff --git a/test/portal-identity-gate.test.ts b/test/portal-identity-gate.test.ts index f352027ce..27a4f046d 100644 --- a/test/portal-identity-gate.test.ts +++ b/test/portal-identity-gate.test.ts @@ -32,6 +32,8 @@ describe("user-scoped routes require a portal-verified actor when enforcement is portalIdentitySecret: PID, requireSignedPortalIdentity: true, scheduler: built.scheduler, + identity: built.identity, + auditLog: built.auditLog, }); await new Promise((resolve) => server.listen(0, resolve)); base = `http://localhost:${(server.address() as AddressInfo).port}`; @@ -301,6 +303,74 @@ describe("user-scoped routes require a portal-verified actor when enforcement is assert.equal(claims?.actorId, "U1"); assert.equal(claims?.scopeId, "personal:U1"); }); + + it("reports an owned deactivation without disguising it as an expired portal session", async () => { + await built.identity.deactivate("owned@example.com", "directory-sync", "directory-sync"); + const response = await fetch(`${base}/v1/contexts?principalId=owned@example.com`, { + headers: { "x-portal-identity": await token("owned@example.com") }, + }); + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { + error: "account_deactivated", + message: "This account is deactivated. Ask an administrator to reactivate it.", + reason: "account_deactivated", + source: "directory-sync", + }); + await built.identity.reactivate("owned@example.com"); + }); + + it("reports a deactivation when optional portal enforcement is disabled", async () => { + const permissiveServer = createInsecureTestServer(built.app, { + capabilitySecret: CAP, + portalIdentitySecret: PID, + identity: built.identity, + }); + await new Promise((resolve) => permissiveServer.listen(0, resolve)); + try { + await built.identity.deactivate("manual@example.com"); + const permissiveBase = `http://localhost:${(permissiveServer.address() as AddressInfo).port}`; + const response = await fetch(`${permissiveBase}/v1/session-cap`, { + method: "POST", + headers: { "x-portal-identity": await token("manual@example.com") }, + }); + assert.equal(response.status, 403); + assert.equal(((await response.json()) as { reason?: string }).reason, "account_deactivated"); + } finally { + await built.identity.reactivate("manual@example.com"); + await new Promise((resolve) => permissiveServer.close(() => resolve())); + } + }); + + it("repairs a legacy sync deactivation when a separately verified portal identity asserts itself", async () => { + await built.identity.deactivate("legacy@example.com", "directory-sync"); + const response = await fetch(`${base}/v1/contexts?principalId=legacy@example.com`, { + headers: { "x-portal-identity": await token("legacy@example.com") }, + }); + assert.equal(response.status, 200); + assert.equal(built.identity.deactivation("legacy@example.com"), null); + assert.ok( + (await built.auditLog.events()).some( + (event) => + event.action === "principal.reactivate" && + event.principalId === "legacy@example.com" && + event.resource === "portal-identity-recovery", + ), + ); + }); + + it("does not repair a legacy sync deactivation through impersonation", async () => { + await built.identity.deactivate("impersonated@example.com", "directory-sync"); + const impersonated = await mintSignedPayload( + { p: "impersonated@example.com", imp: "admin@example.com", exp: Date.now() + 60_000 }, + PID, + ); + const response = await fetch(`${base}/v1/contexts?principalId=impersonated@example.com`, { + headers: { "x-portal-identity": impersonated }, + }); + assert.equal(response.status, 403); + assert.equal(built.identity.deactivation("impersonated@example.com")?.source, "directory-sync"); + await built.identity.reactivate("impersonated@example.com"); + }); }); describe("service-to-service writes are classified", () => { diff --git a/test/portal-identity-ownership-e2e.test.ts b/test/portal-identity-ownership-e2e.test.ts new file mode 100644 index 000000000..369ca0e15 --- /dev/null +++ b/test/portal-identity-ownership-e2e.test.ts @@ -0,0 +1,68 @@ +import "./support/auto-fake-sprites.ts"; + +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { createServer as createHttpServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createServer as createCoreServer } from "../src/api/server.ts"; +import { buildApp } from "../src/wiring.ts"; +import { mintPortalIdentity, PORTAL_IDENTITY_HEADER } from "../plugins/chassis/src/portal-identity.ts"; +import { testConfig } from "./support/test-config.ts"; + +const SOURCE_SECRET = "issue-304-source-secret-000000000001"; +const CAPABILITY_SECRET = "issue-304-capability-secret-00000001"; +const PORTAL_SECRET = "issue-304-portal-secret-000000000001"; + +const listen = async (server: Server): Promise => { + await new Promise((resolve) => server.listen(0, resolve)); + return `http://localhost:${(server.address() as AddressInfo).port}`; +}; + +test("portal identity ownership survives Slack sync and deactivation has an explicit recovery path", async () => { + const built = buildApp(testConfig({ dataDir: mkdtempSync(join(tmpdir(), "portal-identity-ownership-")) })); + await built.identity.hydrate(); + const core = createCoreServer(built.app, { + signingSecret: SOURCE_SECRET, + capabilitySecret: CAPABILITY_SECRET, + portalIdentitySecret: PORTAL_SECRET, + requireSignedPortalIdentity: true, + identity: built.identity, + auditLog: built.auditLog, + }); + const coreBase = await listen(core); + + process.env.CORE_API_URL = coreBase; + process.env.CORE_SIGNING_SECRET = SOURCE_SECRET; + process.env.PORTAL_IDENTITY_SECRET = PORTAL_SECRET; + process.env.WEB_UI_PRINCIPALS = "portal@example.com"; + process.env.ALLOW_UNSIGNED_TEST_IDENTITY = "0"; + const { handler } = await import("../plugins/web-ui/server/index.ts"); + const surface = createHttpServer((req, res) => void handler(req, res)); + const surfaceBase = await listen(surface); + const token = mintPortalIdentity({ p: "portal@example.com", exp: Date.now() + 60_000 }, PORTAL_SECRET); + const headers = { [PORTAL_IDENTITY_HEADER]: token }; + + try { + await built.directory.replace([ + { principalId: "portal@example.com", displayName: "Portal User", type: "internal" }, + ]); + await built.app.upsertDirectory([]); + assert.equal((await fetch(`${surfaceBase}/me`, { headers })).status, 200); + + await built.identity.deactivate("portal@example.com", "directory-sync", "directory-sync"); + const blocked = await fetch(`${surfaceBase}/me`, { headers }); + assert.equal(blocked.status, 403); + assert.equal(((await blocked.json()) as { reason?: string }).reason, "account_deactivated"); + + await built.identity.reactivate("portal@example.com"); + assert.equal((await fetch(`${surfaceBase}/me`, { headers })).status, 200); + } finally { + await Promise.all([ + new Promise((resolve) => surface.close(() => resolve())), + new Promise((resolve) => core.close(() => resolve())), + ]); + } +}); diff --git a/test/postdeploy-smoke.test.ts b/test/postdeploy-smoke.test.ts index 33453e6d9..ee9a0f59c 100644 --- a/test/postdeploy-smoke.test.ts +++ b/test/postdeploy-smoke.test.ts @@ -158,4 +158,22 @@ test("live session smoke proves a model turn, persistence, title, error log, and /returned 500/, ); assert.equal(archivedFailedRequest, true); + + await assert.rejects( + checkLiveSession(config, "http://core.internal:8080", async (input) => { + const path = new URL(String(input)).pathname; + if (path === "/v1/turns") { + return Response.json( + { + error: "account_deactivated", + reason: "account_deactivated", + message: "This account is deactivated. Ask an administrator to reactivate it.", + }, + { status: 403 }, + ); + } + return Response.json({ sessions: [] }); + }), + /account_deactivated.*reactivate/i, + ); }); diff --git a/test/postgres-directory-store.test.ts b/test/postgres-directory-store.test.ts index 0111dec3b..dfe8da85d 100644 --- a/test/postgres-directory-store.test.ts +++ b/test/postgres-directory-store.test.ts @@ -10,7 +10,7 @@ before(async () => { const pg = (await import("pg")).default; const p = new pg.Pool({ connectionString: URL }); await p.query( - "DROP TABLE IF EXISTS directory_members, directory_channels, directory_channel_members, directory_group_members, directory_sync, directory_meta CASCADE", + "DROP TABLE IF EXISTS directory_members, directory_member_ownership, directory_channels, directory_channel_members, directory_group_members, directory_sync, directory_meta CASCADE", ); await p.end(); }); @@ -32,6 +32,80 @@ const seed = async (store: ReturnType) => { ]); }; +test("pg directory: upgrade claims legacy Slack rows without claiming portal-only rows", { skip }, async () => { + const pg = (await import("pg")).default; + const raw = new pg.Pool({ connectionString: URL }); + try { + await raw.query(`CREATE TABLE directory_members( + org_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + display_name TEXT NOT NULL, + display_name_lc TEXT NOT NULL, + type TEXT NOT NULL, + slack_id TEXT, + PRIMARY KEY (org_id, principal_id) + )`); + await raw.query( + `INSERT INTO directory_members(org_id, principal_id, display_name, display_name_lc, type, slack_id) + VALUES ('default-org', 'slack@example.com', 'Slack Email', 'slack email', 'internal', 'U123ABC'), + ('default-org', 'U456DEF', 'Slack ID', 'slack id', 'internal', NULL), + ('default-org', 'portal@example.com', 'Portal', 'portal', 'internal', NULL)`, + ); + } finally { + await raw.end(); + } + + const store = createPostgresDirectoryStore(URL!); + assert.equal((await store.get("slack@example.com"))?.identitySource, "directory-sync"); + assert.equal((await store.get("U456DEF"))?.identitySource, "directory-sync"); + assert.equal((await store.get("portal@example.com"))?.identitySource, undefined); + + const legacyWriter = new pg.Pool({ connectionString: URL }); + try { + await legacyWriter.query("DELETE FROM directory_members WHERE principal_id = 'portal@example.com'"); + } finally { + await legacyWriter.end(); + } + await store.replace([ + { + principalId: "slack@example.com", + displayName: "Slack Email", + type: "internal", + slackId: "U123ABC", + identitySource: "directory-sync", + }, + { + principalId: "portal@example.com", + displayName: "Portal in Slack", + type: "internal", + slackId: "U999XYZ", + identitySource: "directory-sync", + }, + ]); + assert.equal((await store.get("portal@example.com"))?.identitySource, undefined); + + const oldWriter = new pg.Pool({ connectionString: URL }); + try { + await oldWriter.query("UPDATE directory_members SET identity_source = NULL"); + } finally { + await oldWriter.end(); + } + const newReader = createPostgresDirectoryStore(URL!); + assert.equal((await newReader.get("slack@example.com"))?.identitySource, "directory-sync"); + assert.equal((await newReader.get("portal@example.com"))?.identitySource, undefined); + + await newReader.replace([ + { + principalId: "portal@example.com", + displayName: "Portal Reintroduced by Slack", + type: "internal", + slackId: "U999XYZ", + identitySource: "directory-sync", + }, + ]); + assert.equal((await newReader.get("portal@example.com"))?.identitySource, undefined); +}); + test( "pg directory: indexed resolve — exact id, prefix, ambiguity, none; guests unaddressable (G1)", { skip }, @@ -84,12 +158,19 @@ test("pg directory: exact channel-id resolution has a matching expression index" test("pg directory: slackId round-trips through replace → get/list/resolve; a change re-writes", { skip }, async () => { const store = createPostgresDirectoryStore(URL!); await store.replace([ - { principalId: "eve@acme.com", displayName: "Eve", type: "internal", slackId: "U9" }, + { + principalId: "eve@acme.com", + displayName: "Eve", + type: "internal", + slackId: "U9", + identitySource: "directory-sync", + }, { principalId: "U5", displayName: "Dana", type: "internal" }, ]); assert.equal((await store.get("eve@acme.com"))?.slackId, "U9"); assert.equal((await store.get("U5"))?.slackId, undefined); assert.equal((await store.list()).find((m) => m.principalId === "eve@acme.com")?.slackId, "U9"); + assert.equal((await store.list()).find((m) => m.principalId === "eve@acme.com")?.identitySource, "directory-sync"); const r = await store.resolve("Eve"); assert.equal(r.kind === "one" && r.member.slackId, "U9");