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
+
+ Your portal session is valid, but this account is inactive in QM. Ask an administrator to reactivate it.
+
+
+ `);
+}
+
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");