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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ CAPABILITY_SECRET=
PORTAL_IDENTITY_SECRET=
CONNECTOR_SECRET_KEY=
SKILL_SIGNING_SECRET=
INBOX_USERS=

RATE_LIMIT_PER_WINDOW=60
RATE_LIMIT_WINDOW_MS=60000
Expand Down
12 changes: 12 additions & 0 deletions cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,25 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [
description: "HMAC key shared by core and surface plugins.",
generate: MINT_LOCALLY,
},
{
name: "INBOX_USERS",
service: "web-ui",
required: false,
description: "Comma-separated principals allowed to access Inbox, Calendar, and Design System.",
},
{
name: "CORE_SIGNING_SECRET",
service: "admin",
required: true,
description: "HMAC key shared by core and surface plugins.",
generate: MINT_LOCALLY,
},
{
name: "INBOX_USERS",
service: "admin",
required: false,
description: "Comma-separated principals allowed to access Inbox, Calendar, and Design System.",
},
{
name: "OIDC_CLIENT_ID",
service: "portal",
Expand Down
5 changes: 4 additions & 1 deletion cli/test/aws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2712,7 +2712,10 @@ test("AWS optional-secret activation restores prior tasks when a later service r
"web-ui": state.services["acme-web-ui"].taskDefinition,
};
const arns = Object.fromEntries(
required.map((secret) => [secret.name, "arn:aws:secretsmanager:us-west-2:123456789012:secret:test-AbCdEf"]),
[...required.map((secret) => secret.name), "INBOX_USERS"].map((name) => [
name,
"arn:aws:secretsmanager:us-west-2:123456789012:secret:test-AbCdEf",
]),
);
for (const workload of ["core", "web-ui"] as const) {
const repository = secretsConfig.aws!.services[workload]!.ecrRepository;
Expand Down
12 changes: 12 additions & 0 deletions cli/test/secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,18 @@ test("PORTAL_IDENTITY_SECRET reaches every service that signs or verifies a port
);
});

test("INBOX_USERS is one optional secret shared by web-ui and admin", () => {
const config = makeConfig({ services: ["core", "web-ui", "admin"] });
const allowlist = secretByName(config, "INBOX_USERS");
assert.equal(allowlist.required, false);
assert.deepEqual(allowlist.services, ["admin", "web-ui"]);
assert.deepEqual([...secretDestinations(allowlist).keys()], ["web-ui"]);
assert.deepEqual(runtimeSecretNames("web-ui", allowlist), ["INBOX_USERS"]);
assert.deepEqual(runtimeSecretNames("admin", allowlist), ["INBOX_USERS"]);
assert.match(renderEnvExample(config), /Inbox, Calendar, and Design System\. \(admin, web-ui\)/);
assert.match(renderEnvExample(config), /^# INBOX_USERS=\s{2}# optional$/m);
});

test("the invitation-email pair reaches core as optional secrets on every topology", () => {
for (const name of ["RESEND_API_KEY", "AUTH_EMAIL_FROM"]) {
const alone = secretByName(makeConfig(), name);
Expand Down
3 changes: 3 additions & 0 deletions deploy/stacks/acme/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ CONNECTOR_SECRET_KEY=
# Generate with: openssl rand -hex 32
CORE_SIGNING_SECRET=

# Comma-separated principals allowed to access Inbox, Calendar, and Design System. (admin, web-ui)
# INBOX_USERS= # optional

# Deployment-specific OIDC client identifier when it should not be committed. (portal)
OIDC_CLIENT_ID=

Expand Down
3 changes: 2 additions & 1 deletion plugins/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ The admin tabs render through the Lit modules in `ui/`, with `ui/admin.ts` as th
The shared admin controller retains routing, API calls, and change-review dialogs. Successful saves commit the submitted snapshot, preserving newer edits made while a request was in flight. Scope and render generations prevent stale requests from replacing a newer page. Related settings refresh independently so changing one section cannot discard neighboring drafts. Run `npm test` and `npm run typecheck` from this directory after changing the UI.

Env: `CORE_API_URL` (default `http://localhost:8080`), `CORE_ORG_ID` (default `acme`),
`PORT` (default `8090`) and `CORE_SIGNING_SECRET` (required outside isolated development). The
`PORT` (default `8090`), `CORE_SIGNING_SECRET` (required outside isolated development), and
`INBOX_USERS` (the same comma-separated principal allowlist used by Inbox and Calendar). The
portal also supplies a short-lived `x-portal-identity` token, which this surface forwards to core.
There is **no** `ADMIN_PRINCIPALS` — admin identity + role + scope live solely in the core's
durable, mutable `admin_grants` store, and this surface derives admin status from it via
Expand Down
6 changes: 5 additions & 1 deletion plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6618,6 +6618,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
];

const VIEWS = [...SECTIONS.flatMap((s) => s.views), "onboarding", "user", "ackemoji", "keychain"];
let permissions = [];
function prepareParityCards() {
const content = document.querySelector("#view-governance .governance-content");
const anchor = $("governance-credentials");
Expand Down Expand Up @@ -7202,6 +7203,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
$("who-name").title = "Signed in as " + me.data.principal;
orgId = me.data.org;
$("who-name").textContent = me.data.principal;
permissions = Array.isArray(me.data.permissions) ? me.data.permissions : [];
if (me.data.isAdmin === false) {
$("who").classList.remove("hidden");
$("na-sub").textContent = me.data.principal;
Expand Down Expand Up @@ -7260,6 +7262,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
nav.appendChild(h);
}
s.views.forEach((v) => {
if (v === "design-system" && !permissions.includes("inbox")) return;
const b = document.createElement("button");
b.className = "tab" + (s.label ? "" : " tab-top") + (v === view ? " active" : "");
if (v === "design-system") b.classList.add("tab-footer");
Expand Down Expand Up @@ -7354,7 +7357,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>

const turnRaw = p.get("turn");
let resolvedView = DEFAULT_VIEW;
if (VIEWS.includes(v)) resolvedView = v;
if (VIEWS.includes(v) && (v !== "design-system" || permissions.includes("inbox"))) resolvedView = v;
else if (session) resolvedView = "history";
return {
view: resolvedView,
Expand Down Expand Up @@ -7515,6 +7518,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
});
}
function setView(v) {
if (v === "design-system" && !permissions.includes("inbox")) return;
go({
view: v,
scope: v === "history" || v === "errors" ? "org:" + orgId : scope,
Expand Down
14 changes: 13 additions & 1 deletion plugins/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { json, readBody, cookie, gzipAccepted } from "../../chassis/src/http.ts"
import { createBrandingCache, injectBranding, type OrgBranding } from "../../chassis/src/branding.ts";
import { verifyPortalIdentity, PORTAL_IDENTITY_HEADER } from "../../chassis/src/portal-identity.ts";
import { errMessage } from "../../chassis/src/errors.ts";
import { principalInAllowlist } from "../../chassis/src/principal-allowlist.ts";
import {
CORE_API_URL as CORE,
CORE_ORG_ID as ORG,
Expand Down Expand Up @@ -96,6 +97,9 @@ const cookiePrincipal = (req: IncomingMessage): string | null => {
return principal ?? (!CORE_SIGNING_SECRET || ALLOW_UNSIGNED_TEST_IDENTITY ? cookie(req, "admin") : null);
};

const inboxPermissions = (principal: string): string[] =>
principalInAllowlist(principal, process.env.INBOX_USERS) ? ["inbox"] : [];
Comment thread
evebouf marked this conversation as resolved.

const portalTokenStore = new AsyncLocalStorage<string | undefined>();
function portalIdentityHeader(): Record<string, string> {
const t = portalTokenStore.getStore();
Expand Down Expand Up @@ -391,7 +395,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (!p) return json(res, 401, { error: "signed_out" });
const who = await coreWhoami(p);
if (!who) return json(res, 502, { error: "core_unreachable", message: "could not verify admin status" });
return json(res, 200, { principal: p, org: ORG, ...who });
return json(res, 200, { principal: p, org: ORG, ...who, permissions: inboxPermissions(p) });
}
if (method === "POST" && pathname === "/api/logout") {
res.writeHead(200, {
Expand Down Expand Up @@ -498,6 +502,14 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
return forward(req, res, principal, "GET", `/v1/admin/${rest}${url.search}`);
}

if (method === "GET" && ["/design-system", "/design-system/", "/design", "/design/"].includes(pathname)) {
const viewer = cookiePrincipal(req);
if (!viewer || !principalInAllowlist(viewer, process.env.INBOX_USERS)) {
return json(res, 404, { error: "not_found" });
}
return serveShell();
}

if (method === "GET" && !pathname.startsWith("/api/") && !pathname.startsWith("/deployments/")) {
return serveShell();
}
Expand Down
29 changes: 20 additions & 9 deletions plugins/admin/test/branding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ await new Promise<void>((r) => core.listen(0, r));
process.env.CORE_API_URL = `http://localhost:${(core.address() as AddressInfo).port}`;
process.env.CORE_ORG_ID = "acme";
process.env.CORE_SIGNING_SECRET = "admin-branding-test-secret";
process.env.INBOX_USERS = "U-admin";

const { server } = await import("../src/index.ts");
await new Promise<void>((r) => server.listen(0, r));
Expand Down Expand Up @@ -98,13 +99,23 @@ test("the brand icon is a CSS variable the org can point at its own image", () =
});

test("design system routes embed the shared component library and retain the script CSP", async () => {
const response = await fetch(base + "/design-system");
assert.equal(response.status, 200);
const html = await response.text();
const css = readFileSync(new URL("../public/admin-components.css", import.meta.url), "utf8");
assert.ok(html.includes("<style data-admin-components>" + css + "</style>"));
const script = html.match(/<script>([\s\S]*?)<\/script>/i)?.[1];
assert.ok(script);
const hash = createHash("sha256").update(script).digest("base64");
assert.ok(response.headers.get("content-security-policy")?.includes("sha256-" + hash));
for (const path of ["/design-system", "/design"]) {
const response = await fetch(base + path, { headers: { cookie: "admin=U-admin" } });
assert.equal(response.status, 200);
const html = await response.text();
const css = readFileSync(new URL("../public/admin-components.css", import.meta.url), "utf8");
assert.ok(html.includes("<style data-admin-components>" + css + "</style>"));
const script = html.match(/<script>([\s\S]*?)<\/script>/i)?.[1];
assert.ok(script);
const hash = createHash("sha256").update(script).digest("base64");
assert.ok(response.headers.get("content-security-policy")?.includes("sha256-" + hash));
}
});

test("design system routes use the inbox allowlist", async () => {
for (const path of ["/design-system", "/design-system/", "/design", "/design/"]) {
const denied = await fetch(base + path, { headers: { cookie: "admin=U-rando" } });
assert.equal(denied.status, 404);
assert.deepEqual(await denied.json(), { error: "not_found" });
}
});
7 changes: 6 additions & 1 deletion plugins/admin/test/default-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,15 @@ test("admin shell defaults bare admin URLs to org history", () => {
assert.match(html, /let view = DEFAULT_VIEW;/);
assert.match(
html,
/let resolvedView = DEFAULT_VIEW;\s*if \(VIEWS\.includes\(v\)\) resolvedView = v;\s*else if \(session\) resolvedView = "history";[\s\S]*view: resolvedView/,
/let resolvedView = DEFAULT_VIEW;\s*if \(VIEWS\.includes\(v\) && \(v !== "design-system" \|\| permissions\.includes\("inbox"\)\)\) resolvedView = v;\s*else if \(session\) resolvedView = "history";[\s\S]*view: resolvedView/,
);
});

test("the design system follows the inbox permission", () => {
assert.match(html, /if \(v === "design-system" && !permissions\.includes\("inbox"\)\) return;/);
assert.match(html, /permissions = Array\.isArray\(me\.data\.permissions\) \? me\.data\.permissions : \[\];/);
});

test("connector setup uses reactive forms with write-only Slack credentials", () => {
assert.match(html, /governanceUI.integrations.configure/);
assert.match(html, /id="conn-client-secret"/);
Expand Down
10 changes: 10 additions & 0 deletions plugins/admin/test/principal-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { principalInAllowlist } from "../../chassis/src/principal-allowlist.ts";

test("principal allowlists match email addresses case-insensitively and support all", () => {
assert.equal(principalInAllowlist("Alice@Example.com", "other@example.com, alice@example.COM"), true);
assert.equal(principalInAllowlist("alice@example.com", "all"), true);
assert.equal(principalInAllowlist("alice@example.com", "other@example.com"), false);
assert.equal(principalInAllowlist("", "all"), false);
});
5 changes: 4 additions & 1 deletion plugins/admin/test/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const corePort = (core.address() as AddressInfo).port;

process.env.CORE_API_URL = `http://localhost:${corePort}`;
process.env.CORE_SIGNING_SECRET = "admin-whoami-test-secret";
process.env.INBOX_USERS = "U-admin";

const { server } = await import("../src/index.ts");
await new Promise<void>((r) => server.listen(0, r));
Expand Down Expand Up @@ -79,6 +80,7 @@ test("/api/whoami with cookie admin=U-admin → 200 with the core's admin status
isAdmin: true,
role: "org_admin",
scopeId: "org:acme",
permissions: ["inbox"],
});
assert.equal(lastActor, "U-admin@acme", "x-admin-actor forwarded as <sub>@<org>");
assert.equal(lastSigned, true, "source-auth headers present when CORE_SIGNING_SECRET is set");
Expand All @@ -87,7 +89,7 @@ test("/api/whoami with cookie admin=U-admin → 200 with the core's admin status
test("/api/whoami with cookie admin=U-rando → 200 { isAdmin:false } (any non-empty cookie is trusted identity; the core decides)", async () => {
const r = await api("/api/whoami", "admin=U-rando");
assert.equal(r.status, 200);
assert.deepEqual(await r.json(), { principal: "U-rando", org: "acme", isAdmin: false });
assert.deepEqual(await r.json(), { principal: "U-rando", org: "acme", isAdmin: false, permissions: [] });
});

test("/api/whoami with NO admin cookie → 401 signed_out", async () => {
Expand Down Expand Up @@ -139,6 +141,7 @@ test("a forwarded portal identity is relayed to core (so an enforcing core can v
isAdmin: true,
role: "org_admin",
scopeId: "org:acme",
permissions: ["inbox"],
});
});

Expand Down
8 changes: 8 additions & 0 deletions plugins/chassis/src/principal-allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function principalInAllowlist(principalId: string, configuredPrincipals: string | undefined): boolean {
const principal = principalId.trim().toLowerCase();
if (!principal) return false;
return (configuredPrincipals ?? "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.some((entry) => entry === "all" || entry === principal);
}
12 changes: 3 additions & 9 deletions plugins/web-ui/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { verifyPortalIdentity, PORTAL_IDENTITY_HEADER } from "../../chassis/src/portal-identity.ts";
import { createBrandingCache, injectBranding } from "../../chassis/src/branding.ts";
import { parseSuggestedActivities } from "../../chassis/src/suggested-activities.ts";
import { principalInAllowlist } from "../../chassis/src/principal-allowlist.ts";

import {
CORE_API_URL as CORE,
Expand All @@ -55,15 +56,8 @@ const ALLOW = (process.env.WEB_UI_PRINCIPALS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const INBOX_USERS = new Set(
(process.env.INBOX_USERS ?? "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);

export function isInboxUser(principalId: string): boolean {
return INBOX_USERS.has("all") || INBOX_USERS.has(principalId.trim().toLowerCase());
export function isInboxUser(principalId: string, configuredUsers = process.env.INBOX_USERS): boolean {
return principalInAllowlist(principalId, configuredUsers);
}

export function isLoopsUser(principalId: string, configuredUsers = process.env.LOOPS_USERS): boolean {
Expand Down
2 changes: 1 addition & 1 deletion plugins/web-ui/test/inbox-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test("inbox access rides the existing permissions plumbing", () => {
assert.match(shell, /can\("inbox"\) \? html`\$\{inboxNavRow\(\)\}/);
assert.match(shellState, /if \(view === "inbox" \|\| view === "calendar"\) return can\("inbox"\);/);
assert.match(server, /process\.env\.INBOX_USERS/);
assert.match(server, /INBOX_USERS\.has\("all"\) \|\| INBOX_USERS\.has\(principalId\.trim\(\)\.toLowerCase\(\)\)/);
assert.match(server, /principalInAllowlist\(principalId, configuredUsers\)/);
assert.match(server, /if \(isInboxUser\(user\)\) permissions\.push\("inbox"\);/);
assert.match(inbox, /if \(!can\("inbox"\)\) return;/);
});
Expand Down
Loading