Skip to content
Open
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
2 changes: 1 addition & 1 deletion scripts/no-cloud-scan-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ const exactLegacyHostedEnvUnsetBridgeSpec = {
path: "scripts/run-hermetic-tests.sh",
startAnchor: "run_scrubbed() {\n",
endAnchor: ' "$@"\n',
sha256: "ce4e564e8c894538b10c68df724e1d47e900cd2ff381c3dc80791ff6c275a316",
sha256: "546511fb60593ed8c3853cf6a0851093d92fee6a507cafdcfe6b48bd8efbe139",
};

function locateExactLegacyHostedEnvUnsetBridge(content, path) {
Expand Down
1 change: 1 addition & 0 deletions scripts/run-hermetic-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ run_scrubbed() {
-u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \
-u EMAILS_IDP_TOKEN -u EMAILS_IDP_JWKS_URL \
-u EMAILS_IDP_JWKS_CACHE_SECONDS \
-u EMAILS_IDP_JWKS_MAX_STALE_SECONDS \
-u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \
-u EMAILS_DATABASE_CA_FILE -u EMAILS_API_SIGNING_KEY \
-u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \
Expand Down
141 changes: 141 additions & 0 deletions src/cli/commands/idp-principal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// The `emails self-hosted idp-principal` operator verbs (ADR-0001/0002).
//
// The federation slice had NO CLI surface: creating a grant or throwing the
// revoked_at kill switch meant hand SQL against production. These verbs make a
// grant one command and — the incident path — a revocation one command,
// against the server's own database exactly like `self-hosted key`.
//
// The store is injected, so this suite asserts the COMMAND: argument parsing,
// tenant scoping, and what reaches the store. The store methods themselves are
// proven in idp-multi-grant.test.ts and idp.integration.test.ts.

import { describe, expect, it } from "bun:test";
import { Command } from "commander";
import { registerIdpPrincipalCommands, type IdpPrincipalStore } from "./idp-principal.js";

const TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";

interface Calls {
upserts: unknown[];
revokes: unknown[];
restores: unknown[];
lists: unknown[];
}

function harness(overrides: Partial<IdpPrincipalStore> = {}) {
const calls: Calls = { upserts: [], revokes: [], restores: [], lists: [] };
const outputs: unknown[] = [];
const store: IdpPrincipalStore = {
async upsertIdpPrincipalTenant(input) {
calls.upserts.push(input);
return {
sub: input.sub,
tenantId: input.tenantId,
idpTid: input.idpTid ?? null,
principalType: input.principalType ?? "service",
revokedAt: null,
};
},
async revokeIdpPrincipalTenant(sub, tenantId) {
calls.revokes.push({ sub, tenantId });
return true;
},
async restoreIdpPrincipalTenant(sub, tenantId) {
calls.restores.push({ sub, tenantId });
return true;
},
async listIdpPrincipalTenants(tenantId) {
calls.lists.push(tenantId);
return [
{
sub: "sp-known",
tenantId,
idpTid: null,
principalType: "service",
note: null,
createdAt: "2026-07-01T00:00:00Z",
revokedAt: null,
},
];
},
...overrides,
};
const program = new Command();
program.exitOverride();
const selfHosted = program.command("self-hosted");
registerIdpPrincipalCommands(
selfHosted,
(data) => outputs.push(data),
async () => ({ store, close: async () => {} }),
);
const run = (argv: string[]) => program.parseAsync(["node", "emails", "self-hosted", ...argv]);
return { run, calls, outputs };
}

describe("idp-principal grant", () => {
it("grants sub -> tenant with the pinned IdP tenant and note", async () => {
const { run, calls, outputs } = harness();
await run([
"idp-principal", "grant", "sp-agent-1",
"--tenant", TENANT,
"--idp-tid", "11111111-2222-3333-4444-555555555555",
"--type", "service",
"--note", "ci agent",
]);
expect(calls.upserts).toEqual([
{
sub: "sp-agent-1",
tenantId: TENANT,
idpTid: "11111111-2222-3333-4444-555555555555",
principalType: "service",
note: "ci agent",
},
]);
expect(outputs[0]).toMatchObject({ sub: "sp-agent-1", tenantId: TENANT });
});

it("refuses to grant without an explicit tenant", async () => {
const { run, calls } = harness();
await expect(run(["idp-principal", "grant", "sp-agent-1"])).rejects.toThrow();
expect(calls.upserts).toEqual([]);
});
});

describe("idp-principal revoke — the kill switch", () => {
it("revokes one tenant grant when --tenant is given", async () => {
const { run, calls } = harness();
await run(["idp-principal", "revoke", "sp-agent-1", "--tenant", TENANT]);
expect(calls.revokes).toEqual([{ sub: "sp-agent-1", tenantId: TENANT }]);
});

it("revokes EVERY grant of the sub when --tenant is omitted (incident path, one command)", async () => {
const { run, calls } = harness();
await run(["idp-principal", "revoke", "sp-agent-1"]);
expect(calls.revokes).toEqual([{ sub: "sp-agent-1", tenantId: undefined }]);
});

it("reports a no-op revoke as an error instead of implying the switch was thrown", async () => {
const { run } = harness({
async revokeIdpPrincipalTenant() {
return false;
},
});
await expect(run(["idp-principal", "revoke", "sp-gone"])).rejects.toThrow();
});
});

describe("idp-principal restore and list", () => {
it("restore requires the tenant (deliberate, single-grant act)", async () => {
const { run, calls } = harness();
await run(["idp-principal", "restore", "sp-agent-1", "--tenant", TENANT]);
expect(calls.restores).toEqual([{ sub: "sp-agent-1", tenantId: TENANT }]);
await expect(run(["idp-principal", "restore", "sp-agent-1"])).rejects.toThrow();
});

it("lists a tenant's grants", async () => {
const { run, calls, outputs } = harness();
await run(["idp-principal", "list", "--tenant", TENANT]);
expect(calls.lists).toEqual([TENANT]);
expect(outputs[0]).toMatchObject([{ sub: "sp-known" }]);
});
});
141 changes: 141 additions & 0 deletions src/cli/commands/idp-principal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// `emails self-hosted idp-principal` — the operator surface for IdP-principal
// federation grants (ADR-0001/0002), against the server's own database exactly
// like `self-hosted key`.
//
// Grants are privilege-granting rows (idp_principal_tenants): they decide
// which tenant a verified IdP token may act in, and `revoked_at` on them is
// the ONLY revocation the emails side can enforce within a token's ≤24h life.
// These verbs make a grant auditable and a revocation ONE command during an
// incident — no hand SQL. A re-grant never lifts the kill switch; `restore`
// is the separate, deliberate act that does.
//
// The store is injected so the command surface is testable without a
// database; the default factory wires the self-hosted Postgres pool.

import type { Command } from "commander";
import chalk from "../../lib/chalk-lite.js";
import type { IdpPrincipalMapping } from "../../server/self-hosted/auth/store.js";

/** The slice of AuthStore these verbs need (kept narrow for injection). */
export interface IdpPrincipalStore {
upsertIdpPrincipalTenant(input: {
sub: string;
tenantId: string;
idpTid?: string | null;
principalType?: "user" | "service";
note?: string | null;
createdByUserId?: string | null;
}): Promise<IdpPrincipalMapping | null>;
revokeIdpPrincipalTenant(sub: string, tenantId?: string): Promise<boolean>;
restoreIdpPrincipalTenant(sub: string, tenantId: string): Promise<boolean>;
listIdpPrincipalTenants(tenantId: string): Promise<Array<IdpPrincipalMapping & {
note: string | null;
createdAt: string;
}>>;
}

export type IdpPrincipalStoreFactory = () => Promise<{
store: IdpPrincipalStore;
close: () => Promise<void>;
}>;

/** Default factory: the self-hosted server's own Postgres (like `self-hosted key`). */
async function defaultStoreFactory(): Promise<{ store: IdpPrincipalStore; close: () => Promise<void> }> {
const { getSelfHostedPool, closeSelfHostedPool } = await import("../../server/self-hosted/env.js");
const { AuthStore } = await import("../../server/self-hosted/auth/store.js");
return {
store: new AuthStore(getSelfHostedPool().client),
close: () => closeSelfHostedPool(),
};
}

function grantLine(grant: IdpPrincipalMapping & { note?: string | null; createdAt?: string }): string {
const state = grant.revokedAt ? chalk.red(`revoked ${grant.revokedAt}`) : chalk.green("active");
return `${grant.sub} tenant=${grant.tenantId} idp-tid=${grant.idpTid ?? "-"} type=${grant.principalType} ${state}`;
}

export function registerIdpPrincipalCommands(
selfHosted: Command,
output: (data: unknown, formatted: string) => void,
storeFactory: IdpPrincipalStoreFactory = defaultStoreFactory,
): void {
const idp = selfHosted
.command("idp-principal")
.description("Grant, revoke, restore, and list IdP-principal federation grants");

async function withStore<T>(fn: (store: IdpPrincipalStore) => Promise<T>): Promise<T> {
const { store, close } = await storeFactory();
try {
return await fn(store);
} finally {
await close();
}
}

idp.command("grant <sub>")
.description("Grant an IdP principal (sub) access to ONE tenant; a re-grant never un-revokes")
.requiredOption("--tenant <tenant-id>", "Tenant the principal may act in")
.option("--idp-tid <idp-tenant-id>", "Pin the IdP tenant; a token with a different tid is refused")
.option("--type <type>", "Principal type: user or service", "service")
.option("--note <note>", "Operator note recorded on the grant")
.action(async (sub: string, opts: { tenant: string; idpTid?: string; type: string; note?: string }) => {
const principalType = opts.type;
if (principalType !== "user" && principalType !== "service") {
throw new Error("--type must be 'user' or 'service'");
}
const grant = await withStore((store) =>
store.upsertIdpPrincipalTenant({
sub,
tenantId: opts.tenant,
idpTid: opts.idpTid ?? null,
principalType,
note: opts.note ?? null,
}),
);
if (!grant) throw new Error("the grant could not be persisted");
const revokedWarning = grant.revokedAt
? `\n${chalk.yellow("This grant is REVOKED; a re-grant never lifts the kill switch. Use 'idp-principal restore' to do that deliberately.")}`
: "";
output(grant, `${chalk.green("Granted.")} ${grantLine(grant)}${revokedWarning}`);
});

idp.command("revoke <sub>")
.description("Throw the kill switch: with --tenant one grant, without it EVERY grant of the sub")
.option("--tenant <tenant-id>", "Limit the revocation to one tenant grant")
.action(async (sub: string, opts: { tenant?: string }) => {
const revoked = await withStore((store) => store.revokeIdpPrincipalTenant(sub, opts.tenant));
if (!revoked) {
throw new Error(
opts.tenant
? `no live grant for '${sub}' in tenant ${opts.tenant} — nothing was revoked`
: `no live grants for '${sub}' — nothing was revoked`,
);
}
output(
{ sub, tenantId: opts.tenant ?? null, revoked: true },
chalk.green(opts.tenant ? `Revoked '${sub}' in tenant ${opts.tenant}.` : `Revoked every grant of '${sub}'.`),
);
});

idp.command("restore <sub>")
.description("Deliberately lift the kill switch on ONE (sub, tenant) grant")
.requiredOption("--tenant <tenant-id>", "Tenant whose grant is restored")
.action(async (sub: string, opts: { tenant: string }) => {
const restored = await withStore((store) => store.restoreIdpPrincipalTenant(sub, opts.tenant));
if (!restored) {
throw new Error(`no revoked grant for '${sub}' in tenant ${opts.tenant} — nothing was restored`);
}
output({ sub, tenantId: opts.tenant, restored: true }, chalk.green(`Restored '${sub}' in tenant ${opts.tenant}.`));
});

idp.command("list")
.description("List a tenant's IdP-principal grants, revoked ones included")
.requiredOption("--tenant <tenant-id>", "Tenant whose grants are listed")
.action(async (opts: { tenant: string }) => {
const grants = await withStore((store) => store.listIdpPrincipalTenants(opts.tenant));
output(
grants,
grants.length ? grants.map((grant) => grantLine(grant)).join("\n") : chalk.dim("No idp principal grants."),
);
});
}
2 changes: 2 additions & 0 deletions src/cli/commands/self-hosted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Command } from "commander";
import chalk from "../../lib/chalk-lite.js";
import { closeSelfHostedPool, getSelfHostedPool, requireSigningSecret } from "../../server/self-hosted/env.js";
import { issueSelfHostedApiKey, listSelfHostedApiKeys, revokeSelfHostedApiKey, rotateToEmailsApiKey } from "../../server/self-hosted/keys.js";
import { registerIdpPrincipalCommands } from "./idp-principal.js";
import { handleError } from "../utils.js";

async function keyStore(): Promise<{ store: ApiKeyStore; signingSecret: string }> {
Expand All @@ -14,6 +15,7 @@ async function keyStore(): Promise<{ store: ApiKeyStore; signingSecret: string }

export function registerSelfHostedCommands(program: Command, output: (data: unknown, formatted: string) => void): void {
const selfHosted = program.command("self-hosted").description("Operate your self-hosted Emails deployment");
registerIdpPrincipalCommands(selfHosted, output);
const key = selfHosted.command("key").description("Create, list, and revoke self-hosted API keys");

key.command("create")
Expand Down
Loading
Loading