diff --git a/scripts/no-cloud-scan-lib.mjs b/scripts/no-cloud-scan-lib.mjs index ee9e641b..4142df9b 100644 --- a/scripts/no-cloud-scan-lib.mjs +++ b/scripts/no-cloud-scan-lib.mjs @@ -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) { diff --git a/scripts/run-hermetic-tests.sh b/scripts/run-hermetic-tests.sh index ff8d5686..0a1cfe7d 100755 --- a/scripts/run-hermetic-tests.sh +++ b/scripts/run-hermetic-tests.sh @@ -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 \ diff --git a/src/cli/commands/idp-principal.test.ts b/src/cli/commands/idp-principal.test.ts new file mode 100644 index 00000000..eefed700 --- /dev/null +++ b/src/cli/commands/idp-principal.test.ts @@ -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 = {}) { + 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" }]); + }); +}); diff --git a/src/cli/commands/idp-principal.ts b/src/cli/commands/idp-principal.ts new file mode 100644 index 00000000..84488988 --- /dev/null +++ b/src/cli/commands/idp-principal.ts @@ -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; + revokeIdpPrincipalTenant(sub: string, tenantId?: string): Promise; + restoreIdpPrincipalTenant(sub: string, tenantId: string): Promise; + listIdpPrincipalTenants(tenantId: string): Promise>; +} + +export type IdpPrincipalStoreFactory = () => Promise<{ + store: IdpPrincipalStore; + close: () => Promise; +}>; + +/** Default factory: the self-hosted server's own Postgres (like `self-hosted key`). */ +async function defaultStoreFactory(): Promise<{ store: IdpPrincipalStore; close: () => Promise }> { + 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(fn: (store: IdpPrincipalStore) => Promise): Promise { + const { store, close } = await storeFactory(); + try { + return await fn(store); + } finally { + await close(); + } + } + + idp.command("grant ") + .description("Grant an IdP principal (sub) access to ONE tenant; a re-grant never un-revokes") + .requiredOption("--tenant ", "Tenant the principal may act in") + .option("--idp-tid ", "Pin the IdP tenant; a token with a different tid is refused") + .option("--type ", "Principal type: user or service", "service") + .option("--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 ") + .description("Throw the kill switch: with --tenant one grant, without it EVERY grant of the sub") + .option("--tenant ", "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 ") + .description("Deliberately lift the kill switch on ONE (sub, tenant) grant") + .requiredOption("--tenant ", "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 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."), + ); + }); +} diff --git a/src/cli/commands/self-hosted.ts b/src/cli/commands/self-hosted.ts index 8ea55992..57d473e0 100644 --- a/src/cli/commands/self-hosted.ts +++ b/src/cli/commands/self-hosted.ts @@ -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 }> { @@ -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") diff --git a/src/lib/self-hosted-response-contracts.generated.ts b/src/lib/self-hosted-response-contracts.generated.ts index b08fb308..23fa13eb 100644 --- a/src/lib/self-hosted-response-contracts.generated.ts +++ b/src/lib/self-hosted-response-contracts.generated.ts @@ -15098,6 +15098,581 @@ export const SELF_HOSTED_RESPONSE_CONTRACTS: readonly SelfHostedResponseContract ] } }, + { + "method": "GET", + "operationId": "listIdpPrincipals", + "path": "/v1/idp-principals", + "status": 200, + "schema": { + "type": "object", + "properties": { + "idp_principals": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sub": { + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "idp_tid": { + "type": "string", + "nullable": true + }, + "principal_type": { + "type": "string", + "enum": [ + "user", + "service" + ] + }, + "note": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "sub", + "tenant_id", + "idp_tid", + "principal_type", + "revoked_at" + ] + } + } + }, + "required": [ + "idp_principals" + ] + } + }, + { + "method": "GET", + "operationId": "listIdpPrincipals", + "path": "/v1/idp-principals", + "status": 401, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "GET", + "operationId": "listIdpPrincipals", + "path": "/v1/idp-principals", + "status": 403, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "GET", + "operationId": "listIdpPrincipals", + "path": "/v1/idp-principals", + "status": 500, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "enum": [ + "internal error" + ] + } + }, + "required": [ + "error" + ] + } + }, + { + "method": "POST", + "operationId": "grantIdpPrincipal", + "path": "/v1/idp-principals", + "status": 201, + "schema": { + "type": "object", + "properties": { + "grant": { + "type": "object", + "properties": { + "sub": { + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "idp_tid": { + "type": "string", + "nullable": true + }, + "principal_type": { + "type": "string", + "enum": [ + "user", + "service" + ] + }, + "note": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "required": [ + "sub", + "tenant_id", + "idp_tid", + "principal_type", + "revoked_at" + ] + }, + "warning": { + "type": "string" + } + }, + "required": [ + "grant" + ] + } + }, + { + "method": "POST", + "operationId": "grantIdpPrincipal", + "path": "/v1/idp-principals", + "status": 400, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error" + ] + } + }, + { + "method": "POST", + "operationId": "grantIdpPrincipal", + "path": "/v1/idp-principals", + "status": 401, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "POST", + "operationId": "grantIdpPrincipal", + "path": "/v1/idp-principals", + "status": 403, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "POST", + "operationId": "grantIdpPrincipal", + "path": "/v1/idp-principals", + "status": 413, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "enum": [ + "request body too large" + ] + } + }, + "required": [ + "error" + ] + } + }, + { + "method": "POST", + "operationId": "grantIdpPrincipal", + "path": "/v1/idp-principals", + "status": 500, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "enum": [ + "internal error" + ] + } + }, + "required": [ + "error" + ] + } + }, + { + "method": "DELETE", + "operationId": "revokeIdpPrincipal", + "path": "/v1/idp-principals/{sub}", + "status": 200, + "schema": { + "type": "object", + "properties": { + "revoked": { + "type": "boolean", + "enum": [ + true + ] + }, + "sub": { + "type": "string" + } + }, + "required": [ + "revoked", + "sub" + ] + } + }, + { + "method": "DELETE", + "operationId": "revokeIdpPrincipal", + "path": "/v1/idp-principals/{sub}", + "status": 401, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "DELETE", + "operationId": "revokeIdpPrincipal", + "path": "/v1/idp-principals/{sub}", + "status": 403, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "DELETE", + "operationId": "revokeIdpPrincipal", + "path": "/v1/idp-principals/{sub}", + "status": 500, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "enum": [ + "internal error" + ] + } + }, + "required": [ + "error" + ] + } + }, + { + "method": "POST", + "operationId": "restoreIdpPrincipal", + "path": "/v1/idp-principals/{sub}/restore", + "status": 200, + "schema": { + "type": "object", + "properties": { + "restored": { + "type": "boolean", + "enum": [ + true + ] + }, + "sub": { + "type": "string" + } + }, + "required": [ + "restored", + "sub" + ] + } + }, + { + "method": "POST", + "operationId": "restoreIdpPrincipal", + "path": "/v1/idp-principals/{sub}/restore", + "status": 401, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "POST", + "operationId": "restoreIdpPrincipal", + "path": "/v1/idp-principals/{sub}/restore", + "status": 403, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "POST", + "operationId": "restoreIdpPrincipal", + "path": "/v1/idp-principals/{sub}/restore", + "status": 500, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "enum": [ + "internal error" + ] + } + }, + "required": [ + "error" + ] + } + }, + { + "method": "POST", + "operationId": "revokeIdpPrincipalByPost", + "path": "/v1/idp-principals/{sub}/revoke", + "status": 200, + "schema": { + "type": "object", + "properties": { + "revoked": { + "type": "boolean", + "enum": [ + true + ] + }, + "sub": { + "type": "string" + } + }, + "required": [ + "revoked", + "sub" + ] + } + }, + { + "method": "POST", + "operationId": "revokeIdpPrincipalByPost", + "path": "/v1/idp-principals/{sub}/revoke", + "status": 401, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "POST", + "operationId": "revokeIdpPrincipalByPost", + "path": "/v1/idp-principals/{sub}/revoke", + "status": 403, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "error", + "reason" + ] + } + }, + { + "method": "POST", + "operationId": "revokeIdpPrincipalByPost", + "path": "/v1/idp-principals/{sub}/revoke", + "status": 500, + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "error": { + "type": "string", + "enum": [ + "internal error" + ] + } + }, + "required": [ + "error" + ] + } + }, { "method": "POST", "operationId": "acceptInvite", diff --git a/src/selfhost.ts b/src/selfhost.ts index 837bbf2d..17871be1 100644 --- a/src/selfhost.ts +++ b/src/selfhost.ts @@ -1046,6 +1046,51 @@ export class EmailsSelfHostClient { }); } + /** List this tenant's IdP-principal federation grants (revoked included); tenant operator required */ + async listIdpPrincipals(init?: RequestInit): Promise<{ "idp_principals": Array<{ "sub": string; "tenant_id": string; "idp_tid": string | null; "principal_type": "user" | "service"; "note"?: string | null; "created_at"?: string; "revoked_at": string | null }> }> { + return this.request("GET", `/v1/idp-principals`, { + body: undefined, + query: undefined, + init, + }); + } + + /** Grant an IdP principal (sub) access to the caller's tenant; a re-grant never un-revokes */ + async grantIdpPrincipal(body: { "sub": string; "idp_tid"?: string | null; "principal_type"?: "user" | "service"; "note"?: string | null }, init?: RequestInit): Promise<{ "grant": { "sub": string; "tenant_id": string; "idp_tid": string | null; "principal_type": "user" | "service"; "note"?: string | null; "created_at"?: string; "revoked_at": string | null }; "warning"?: string }> { + return this.request("POST", `/v1/idp-principals`, { + body, + query: undefined, + init, + }); + } + + /** Throw the emails-side kill switch on a federation grant; tenant operator required */ + async revokeIdpPrincipal(sub: string, init?: RequestInit): Promise<{ "revoked": true; "sub": string }> { + return this.request("DELETE", `/v1/idp-principals/${encodeURIComponent(String(sub))}`, { + body: undefined, + query: undefined, + init, + }); + } + + /** Deliberately lift the kill switch on one federation grant; tenant operator required */ + async restoreIdpPrincipal(sub: string, init?: RequestInit): Promise<{ "restored": true; "sub": string }> { + return this.request("POST", `/v1/idp-principals/${encodeURIComponent(String(sub))}/restore`, { + body: undefined, + query: undefined, + init, + }); + } + + /** Compatibility verb for revoking a federation grant */ + async revokeIdpPrincipalByPost(sub: string, init?: RequestInit): Promise<{ "revoked": true; "sub": string }> { + return this.request("POST", `/v1/idp-principals/${encodeURIComponent(String(sub))}/revoke`, { + body: undefined, + query: undefined, + init, + }); + } + /** Accept an invitation and create a tenant-bound session */ async acceptInvite(body: { "token": string; "password"?: string | null; "name"?: string | null }, init?: RequestInit): Promise<{ "session_token": string; "expires_at": string; "user": User; "tenant": Tenant | null; "role": "owner" | "admin" | "member" | "viewer" }> { return this.request("POST", `/v1/invites/accept`, { diff --git a/src/server/self-hosted/api-key-verifier.ts b/src/server/self-hosted/api-key-verifier.ts index a2b58f0b..084b9648 100644 --- a/src/server/self-hosted/api-key-verifier.ts +++ b/src/server/self-hosted/api-key-verifier.ts @@ -23,6 +23,19 @@ import { * (per-app verifiers are audit-free) so an accepted alias key does not also emit * a spurious `app_mismatch` deny line. */ +/** + * The structured, secret-free `[api-auth]` audit line. Carries the tenant + * (`tid`) so the API-key trail is organization-attributable like the IdP + * trail — an audit line that cannot answer "which organization did this" is + * not an audit line. Ids and outcome fields only; never token material. + */ +export function formatApiAuthAuditLine(e: AuthAuditEvent): string { + return ( + `[api-auth] ${e.outcome} app=${e.app} kid=${e.kid ?? "-"} tid=${e.tid ?? "-"} ` + + `reason=${e.reason ?? "-"} ${e.method ?? "-"} ${e.path ?? "-"} status=${e.status}` + ); +} + export function verifyApiKeyWithAliases( options: Omit & { audit?: AuthAuditHook }, apps: readonly [string, ...string[]], diff --git a/src/server/self-hosted/apikey-tenant-binding.test.ts b/src/server/self-hosted/apikey-tenant-binding.test.ts new file mode 100644 index 00000000..fc5e0f9e --- /dev/null +++ b/src/server/self-hosted/apikey-tenant-binding.test.ts @@ -0,0 +1,137 @@ +// Binding the API key's signed `tid` claim to the local tenant mapping. +// +// contracts 0.8.2 added a signed, tamper-evident `tid` tenant claim to API +// keys, and nothing on this server consumed it: the tenant came purely from +// the api_key_tenants DB mapping (the SAFE source — a client-presented claim +// must never pick the tenant), so a key whose signed tid named tenant A while +// the local mapping pointed at tenant B was accepted and silently acted in B. +// Drift between what the key was MINTED for and what it RESOLVES to is now a +// typed refusal, never a silent pass. +// +// Untenanted keys (no tid — every key minted before 0.8.2 and this server's +// own key mint paths today) are untouched: the api-key class keeps working. +// +// The [api-auth] audit line is also asserted to carry the tenant, so both +// credential classes have tenant-attributable audit. +// +// Hermetic: fake query client, no Postgres. + +import { describe, expect, it } from "bun:test"; +import { mintApiKey, verifyApiKey } from "@hasna/contracts/auth"; +import type { TypedQueryClient } from "../../storage-kit/index.js"; +import { resolveRequestContext, type AuthServiceDeps } from "./auth/service.js"; +import { formatApiAuthAuditLine } from "./api-key-verifier.js"; +import { AuthStore } from "./auth/store.js"; +import { RateLimiter } from "./auth/rate-limit.js"; +import { STUB_KEY_STORE, testAuthEnv, testAuthMailer } from "./auth/test-support.js"; +import type { PoolQueryClient } from "../../storage-kit/index.js"; + +const SIGNING_SECRET = "test-signing-secret-do-not-use-in-prod"; +const MAPPED_TENANT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const OTHER_TENANT = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + +function fakeClient(): TypedQueryClient { + const client: TypedQueryClient = { + async query() { + return { rows: [] as never[], rowCount: 0 }; + }, + async many(): Promise { + return [] as T[]; + }, + async get(sql: string): Promise { + if (sql.includes("api_key_tenants")) { + return { tenant_id: MAPPED_TENANT } as unknown as T; + } + return null; + }, + async one(): Promise { + return {} as T; + }, + async execute() {}, + }; + return client; +} + +function deps(): AuthServiceDeps { + return { + authStore: new AuthStore(fakeClient() as unknown as PoolQueryClient), + verifier: verifyApiKey({ app: "emails", signingSecret: SIGNING_SECRET }), + sender: { provider: "ses", send: async () => "provider-message-id" }, + keyStore: STUB_KEY_STORE, + signingSecret: SIGNING_SECRET, + rateLimiter: new RateLimiter(), + mailer: testAuthMailer(), + env: testAuthEnv(), + }; +} + +async function resolve(token: string) { + const url = new URL("http://self-hosted.test/v1/messages"); + const req = new Request(url, { headers: { "x-api-key": token } }); + return resolveRequestContext(deps(), req, url, ["emails:read"]); +} + +describe("signed tid vs api_key_tenants mapping", () => { + it("refuses, typed, a key whose signed tid names a DIFFERENT tenant than the local mapping", async () => { + const { token } = mintApiKey({ + app: "emails", + scopes: ["emails:read"], + signingSecret: SIGNING_SECRET, + tid: OTHER_TENANT, + }); + const result = await resolve(token); + if (result.ok) throw new Error("expected a typed refusal — signed tid disagrees with the mapping"); + const body = (await result.response.json()) as { reason?: string }; + expect({ status: result.response.status, reason: body.reason }).toEqual({ + status: 403, + reason: "tenant_mismatch", + }); + }); + + it("accepts a key whose signed tid MATCHES the mapping", async () => { + const { token } = mintApiKey({ + app: "emails", + scopes: ["emails:read"], + signingSecret: SIGNING_SECRET, + tid: MAPPED_TENANT, + }); + const result = await resolve(token); + if (!result.ok) throw new Error(`expected ok, got ${result.response.status}`); + expect(result.ctx.tenantId).toBe(MAPPED_TENANT); + }); + + it("keeps untenanted keys fully working (the pre-tid credential class)", async () => { + const { token } = mintApiKey({ + app: "emails", + scopes: ["emails:read"], + signingSecret: SIGNING_SECRET, + }); + const result = await resolve(token); + if (!result.ok) throw new Error(`expected ok, got ${result.response.status}`); + expect(result.ctx).toMatchObject({ tenantId: MAPPED_TENANT, principalType: "apikey" }); + }); +}); + +describe("the [api-auth] audit line carries the tenant", () => { + it("prints tid for an allow, and '-' when the key is untenanted", () => { + const base = { + outcome: "allow" as const, + app: "emails", + kid: "kid-1", + reason: null, + scopesRequired: ["emails:read"], + method: "GET", + path: "/v1/messages", + status: 200, + at: "2026-07-29T00:00:00.000Z", + }; + const withTenant = formatApiAuthAuditLine({ ...base, tid: MAPPED_TENANT }); + expect(withTenant).toContain(`tid=${MAPPED_TENANT}`); + expect(withTenant).toContain("kid=kid-1"); + + const untenanted = formatApiAuthAuditLine({ ...base, tid: null }); + expect(untenanted).toContain("tid=-"); + // The line never carries token material — only ids and outcome fields. + expect(withTenant).not.toContain("hasna_"); + }); +}); diff --git a/src/server/self-hosted/auth/idp-jwks-revocation.test.ts b/src/server/self-hosted/auth/idp-jwks-revocation.test.ts new file mode 100644 index 00000000..50d175bf --- /dev/null +++ b/src/server/self-hosted/auth/idp-jwks-revocation.test.ts @@ -0,0 +1,193 @@ +// JWKS revocation honesty for the IdP credential class (ADR-0001). +// +// Two properties the authenticator must hold, distinct from the stale-if-error +// resilience the existing suite pins: +// +// 1. An EMPTY-but-valid JWKS document is a REVOCATION, not an error. The +// standard way an IdP fully withdraws a compromised signing key is to +// publish a JWKS with zero keys; that must REPLACE the cached key set so +// previously-signed tokens fail typed — never be mistaken for a fetch +// failure that keeps the removed key trusted until a process restart. +// +// 2. Staleness has a CEILING. A fetch failure may serve the last-good key set +// for a bounded window only; past it, verification fails with the typed +// 503 the module header promises, instead of trusting unverifiable keys +// forever on a permanently unreachable JWKS endpoint. +// +// Hermetic: stubbed fetch, virtual clock, no network. + +import { describe, expect, it } from "bun:test"; +import { + DEFAULT_IDP_JWKS_CACHE_SECONDS, + DEFAULT_IDP_JWKS_MAX_STALE_SECONDS, + IDP_JWKS_MAX_STALE_SECONDS_ENV, + IDP_JWKS_URL_ENV, + IdpTokenAuthenticator, + buildIdpAuthenticatorFromEnv, + type IdpJwksEvent, +} from "./idp-token.js"; +import { generateTestIdpKey, signTestIdpToken } from "./idp-test-support.js"; + +const key = generateTestIdpKey("kid-revoke"); +const AUDS = ["emails", "mailery"] as const; +const JWKS_URL = "https://idp.example.com/v1/.well-known/jwks.json"; + +function authenticator(options: { + fetchJwks: (url: string) => Promise; + nowMs?: () => number; + cacheSeconds?: number; + maxStaleSeconds?: number; + onEvent?: (event: IdpJwksEvent) => void; +}): IdpTokenAuthenticator { + return new IdpTokenAuthenticator({ + jwksUrl: JWKS_URL, + expectedAudiences: [...AUDS], + ...options, + }); +} + +describe("empty JWKS document — full key revocation", () => { + it("REPLACES the cached key set when the IdP publishes an empty key list (tokens fail typed)", async () => { + let now = 1_000_000_000_000; + let document: unknown = { keys: [key.publicJwk] }; + const auth = authenticator({ + cacheSeconds: 1, + nowMs: () => now, + fetchJwks: async () => document, + }); + const { token } = signTestIdpToken(key, { nowMs: now }); + expect((await auth.authenticate(token)).ok).toBe(true); + + // The IdP withdraws every signing key. Past the cache TTL the next + // authenticate must observe the revocation, not keep the stale key. + document = { keys: [] }; + now += 5_000; + expect(await auth.authenticate(token)).toEqual({ ok: false, reason: "unknown_kid", status: 401 }); + }); + + it("treats a valid document with no USABLE keys the same way (nothing left to verify against)", async () => { + let now = 1_000_000_000_000; + let document: unknown = { keys: [key.publicJwk] }; + const auth = authenticator({ + cacheSeconds: 1, + nowMs: () => now, + fetchJwks: async () => document, + }); + const { token } = signTestIdpToken(key, { nowMs: now }); + expect((await auth.authenticate(token)).ok).toBe(true); + + document = { keys: [{ kty: "RSA", kid: "rsa-1", n: "xx", e: "AQAB" }] }; + now += 5_000; + expect(await auth.authenticate(token)).toEqual({ ok: false, reason: "unknown_kid", status: 401 }); + }); + + it("keeps the revocation distinct from a fetch ERROR: a later re-publish restores verification", async () => { + let now = 1_000_000_000_000; + let document: unknown = { keys: [key.publicJwk] }; + const events: IdpJwksEvent[] = []; + const auth = authenticator({ + cacheSeconds: 1, + nowMs: () => now, + fetchJwks: async () => document, + onEvent: (event) => events.push(event), + }); + const { token } = signTestIdpToken(key, { nowMs: now }); + expect((await auth.authenticate(token)).ok).toBe(true); + + document = { keys: [] }; + now += 5_000; + expect((await auth.authenticate(token)).ok).toBe(false); + // The empty set arrived through the REFRESH path (kids: []), not the error path. + expect(events.some((e) => e.type === "refresh" && e.kids?.length === 0)).toBe(true); + expect(events.every((e) => e.type !== "error")).toBe(true); + + document = { keys: [key.publicJwk] }; + now += 5_000; + expect((await auth.authenticate(token)).ok).toBe(true); + }); + + it("still refuses a MALFORMED document as an error, serving the last-good keys within the ceiling", async () => { + let now = 1_000_000_000_000; + let document: unknown = { keys: [key.publicJwk] }; + const auth = authenticator({ + cacheSeconds: 1, + nowMs: () => now, + fetchJwks: async () => document, + }); + const { token } = signTestIdpToken(key, { nowMs: now }); + expect((await auth.authenticate(token)).ok).toBe(true); + + document = { nonsense: true }; + now += 5_000; + // Malformed is NOT a revocation: stale-if-error still applies inside the ceiling. + expect((await auth.authenticate(token)).ok).toBe(true); + }); +}); + +describe("maximum staleness ceiling", () => { + it("fails typed-503 once a failing JWKS endpoint leaves the cached keys older than the ceiling", async () => { + let now = 1_000_000_000_000; + let healthy = true; + const auth = authenticator({ + cacheSeconds: 1, + maxStaleSeconds: 60, + nowMs: () => now, + fetchJwks: async () => { + if (!healthy) throw new Error("idp unreachable"); + return { keys: [key.publicJwk] }; + }, + }); + const token = () => signTestIdpToken(key, { nowMs: now }).token; + expect((await auth.authenticate(token())).ok).toBe(true); + + healthy = false; + // Inside the ceiling: stale-if-error keeps serving. + now += 30_000; + expect((await auth.authenticate(token())).ok).toBe(true); + // Past the ceiling: the last-good keys are no longer evidence — typed 503. + now += 40_000; + expect(await auth.authenticate(token())).toEqual({ + ok: false, + reason: "jwks_unavailable", + status: 503, + }); + // Recovery is possible the moment the endpoint answers again. + healthy = true; + expect((await auth.authenticate(token())).ok).toBe(true); + }); + + it("has a bounded default ceiling and rejects a ceiling below the cache TTL at boot", () => { + expect(DEFAULT_IDP_JWKS_MAX_STALE_SECONDS).toBeGreaterThanOrEqual(DEFAULT_IDP_JWKS_CACHE_SECONDS); + // A day is the outstanding-token lifetime; trusting keys longer than that + // has no justification the module header could honor. + expect(DEFAULT_IDP_JWKS_MAX_STALE_SECONDS).toBeLessThanOrEqual(86_400); + + expect(() => + buildIdpAuthenticatorFromEnv( + { + [IDP_JWKS_URL_ENV]: JWKS_URL, + [IDP_JWKS_MAX_STALE_SECONDS_ENV]: "not-a-number", + }, + [...AUDS], + ), + ).toThrow(IDP_JWKS_MAX_STALE_SECONDS_ENV); + expect(() => + buildIdpAuthenticatorFromEnv( + { + [IDP_JWKS_URL_ENV]: JWKS_URL, + [IDP_JWKS_MAX_STALE_SECONDS_ENV]: "1", + }, + [...AUDS], + ), + ).toThrow(IDP_JWKS_MAX_STALE_SECONDS_ENV); + + const auth = buildIdpAuthenticatorFromEnv( + { + [IDP_JWKS_URL_ENV]: JWKS_URL, + [IDP_JWKS_MAX_STALE_SECONDS_ENV]: "7200", + }, + [...AUDS], + ); + expect(auth).toBeInstanceOf(IdpTokenAuthenticator); + }); +}); diff --git a/src/server/self-hosted/auth/idp-multi-grant.test.ts b/src/server/self-hosted/auth/idp-multi-grant.test.ts new file mode 100644 index 00000000..e32c1a24 --- /dev/null +++ b/src/server/self-hosted/auth/idp-multi-grant.test.ts @@ -0,0 +1,209 @@ +// Multi-grant keying for idp_principal_tenants (ADR-0001 Phase 1 follow-up). +// +// The table was keyed on `sub` alone with `ON CONFLICT (sub) DO UPDATE ... +// revoked_at = NULL`, which had two silent effects: granting a principal +// access to tenant B unauditably REVOKED its access to tenant A (the row was +// re-pointed), and re-running a grant for a principal an operator had +// deliberately killed un-revoked it. The table is now keyed on +// (sub, tenant_id): one principal can hold several tenant grants, a re-grant +// touches only its own (sub, tenant) row, and a re-grant NEVER resurrects a +// revoked mapping — restoring one is a separate, deliberate operation. +// +// Hermetic: fake query clients capturing SQL; the real-Postgres round-trip +// lives in idp.integration.test.ts. + +import { describe, expect, it } from "bun:test"; +import type { PoolQueryClient } from "../../../storage-kit/index.js"; +import { emailsSelfHostedMigrations } from "../migrations.js"; +import { AuthStore } from "./store.js"; +import { resolveRequestContext, type AuthServiceDeps } from "./service.js"; +import { IdpTokenAuthenticator } from "./idp-token.js"; +import { generateTestIdpKey, signTestIdpToken } from "./idp-test-support.js"; +import { RateLimiter } from "./rate-limit.js"; +import { STUB_KEY_STORE, testAuthEnv, testAuthMailer } from "./test-support.js"; +import { verifyApiKey } from "@hasna/contracts/auth"; + +const key = generateTestIdpKey("kid-multi"); +const IDP_TID = "11111111-2222-3333-4444-555555555555"; +const TENANT_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const TENANT_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + +interface MappingRow { + sub: string; + tenant_id: string; + idp_tid: string | null; + principal_type: string; + revoked_at: string | null; +} + +interface Captured { + sql: string; + params: readonly unknown[] | undefined; +} + +function fakeClient(rows: MappingRow[], captured: Captured[] = []): PoolQueryClient { + const client = { + async query(sql: string, params?: readonly unknown[]) { + captured.push({ sql, params }); + return { rows: [] as never[], rowCount: 0 }; + }, + async many(sql: string, params?: readonly unknown[]): Promise { + captured.push({ sql, params }); + if (sql.includes("idp_principal_tenants")) { + return rows.filter((row) => row.sub === String(params?.[0])) as T[]; + } + return [] as T[]; + }, + async get(sql: string, params?: readonly unknown[]): Promise { + captured.push({ sql, params }); + if (sql.includes("idp_principal_tenants")) { + return (rows.find((row) => row.sub === String(params?.[0])) as T | undefined) ?? null; + } + return null; + }, + async one(): Promise { + return {} as T; + }, + async execute() {}, + async close() {}, + async transaction(fn: (client: unknown) => Promise): Promise { + return fn(client); + }, + }; + return client as unknown as PoolQueryClient; +} + +function deps(rows: MappingRow[]): AuthServiceDeps { + const client = fakeClient(rows); + return { + authStore: new AuthStore(client), + verifier: verifyApiKey({ app: "emails", signingSecret: "test-signing-secret-do-not-use-in-prod" }), + sender: { provider: "ses", send: async () => "provider-message-id" }, + keyStore: STUB_KEY_STORE, + signingSecret: "test-signing-secret-do-not-use-in-prod", + rateLimiter: new RateLimiter(), + mailer: testAuthMailer(), + env: testAuthEnv(), + idpAuthenticator: new IdpTokenAuthenticator({ + jwksUrl: "https://idp.example.com/v1/.well-known/jwks.json", + expectedAudiences: ["emails", "mailery"], + fetchJwks: async () => ({ keys: [key.publicJwk] }), + }), + }; +} + +function row(overrides: Partial): MappingRow { + return { + sub: "sp-agent-1", + tenant_id: TENANT_A, + idp_tid: IDP_TID, + principal_type: "service", + revoked_at: null, + ...overrides, + }; +} + +async function resolve(d: AuthServiceDeps, sub = "sp-agent-1") { + const url = new URL("http://self-hosted.test/v1/messages"); + const { token } = signTestIdpToken(key, { sub, tid: IDP_TID, scope: ["emails:read"] }); + const req = new Request(url, { headers: { Authorization: `Bearer ${token}` } }); + return resolveRequestContext(d, req, url, ["emails:read"]); +} + +describe("resolution across multiple tenant grants for one sub", () => { + it("resolves through the single LIVE grant when the principal's other grant is revoked", async () => { + const d = deps([ + row({ tenant_id: TENANT_A, revoked_at: "2026-07-01T00:00:00Z" }), + row({ tenant_id: TENANT_B }), + ]); + const result = await resolve(d); + if (!result.ok) throw new Error(`expected ok, got ${result.response.status}`); + expect(result.ctx.tenantId).toBe(TENANT_B); + }); + + it("refuses typed when several grants are simultaneously live (never picks one silently)", async () => { + const d = deps([row({ tenant_id: TENANT_A }), row({ tenant_id: TENANT_B })]); + const result = await resolve(d); + if (result.ok) throw new Error("expected a typed refusal"); + const body = (await result.response.json()) as { reason?: string }; + expect({ status: result.response.status, reason: body.reason }).toEqual({ + status: 403, + reason: "idp_grant_ambiguous", + }); + }); + + it("still reports every-grant-revoked as the kill switch, and no grants as no_tenant", async () => { + const revoked = await resolve( + deps([ + row({ tenant_id: TENANT_A, revoked_at: "2026-07-01T00:00:00Z" }), + row({ tenant_id: TENANT_B, revoked_at: "2026-07-02T00:00:00Z" }), + ]), + ); + if (revoked.ok) throw new Error("expected refusal"); + expect(((await revoked.response.json()) as { reason?: string }).reason).toBe("idp_principal_revoked"); + + const unmapped = await resolve(deps([]), "sp-unmapped"); + if (unmapped.ok) throw new Error("expected refusal"); + expect(((await unmapped.response.json()) as { reason?: string }).reason).toBe("no_tenant"); + }); +}); + +describe("grant persistence — (sub, tenant_id) keying", () => { + it("upserts on the composite key and NEVER writes revoked_at (a re-grant cannot resurrect a kill)", async () => { + const captured: Captured[] = []; + const store = new AuthStore(fakeClient([], captured)); + await store.upsertIdpPrincipalTenant({ sub: "sp-agent-1", tenantId: TENANT_A, idpTid: IDP_TID }); + const insert = captured.find((c) => c.sql.includes("INSERT INTO idp_principal_tenants")); + expect(insert).toBeDefined(); + expect(insert!.sql).toContain("ON CONFLICT (sub, tenant_id)"); + // The DO UPDATE arm must not touch the kill switch (reading it back in a + // RETURNING clause is fine; SETTING it is not). + const from = insert!.sql.indexOf("DO UPDATE"); + const to = insert!.sql.includes("RETURNING") ? insert!.sql.indexOf("RETURNING") : insert!.sql.length; + expect(insert!.sql.slice(from, to)).not.toContain("revoked_at"); + }); + + it("revokes scoped to one tenant when asked, and across all of a sub's grants for the incident path", async () => { + const captured: Captured[] = []; + const store = new AuthStore(fakeClient([], captured)); + await store.revokeIdpPrincipalTenant("sp-agent-1", TENANT_A); + const scoped = captured.find((c) => c.sql.includes("UPDATE idp_principal_tenants")); + expect(scoped).toBeDefined(); + expect(scoped!.sql).toContain("tenant_id"); + expect(scoped!.params).toEqual(["sp-agent-1", TENANT_A]); + + captured.length = 0; + await store.revokeIdpPrincipalTenant("sp-agent-1"); + const all = captured.find((c) => c.sql.includes("UPDATE idp_principal_tenants")); + expect(all).toBeDefined(); + expect(all!.sql).not.toContain("tenant_id ="); + expect(all!.params).toEqual(["sp-agent-1"]); + }); + + it("restores a revoked grant only through the explicit restore operation", async () => { + const captured: Captured[] = []; + const store = new AuthStore(fakeClient([], captured)); + await store.restoreIdpPrincipalTenant("sp-agent-1", TENANT_A); + const restore = captured.find( + (c) => c.sql.includes("UPDATE idp_principal_tenants") && c.sql.includes("revoked_at = NULL"), + ); + expect(restore).toBeDefined(); + expect(restore!.params).toEqual(["sp-agent-1", TENANT_A]); + }); +}); + +describe("migration 0022 — composite keying is declared additively", () => { + it("ships an idempotent migration that keys the table on (sub, tenant_id)", () => { + const migration = emailsSelfHostedMigrations().find( + (m) => m.id === "0022_idp_principal_tenants_multi_grant", + ); + expect(migration).toBeDefined(); + expect(migration!.sql).toContain("idp_principal_tenants_sub_tenant_key"); + expect(migration!.sql).toContain("(sub, tenant_id)"); + // The unique composite index must exist BEFORE the sub-only primary key is + // dropped, so uniqueness never lapses mid-migration. + expect(migration!.sql.indexOf("idp_principal_tenants_sub_tenant_key")).toBeLessThan( + migration!.sql.indexOf("DROP CONSTRAINT IF EXISTS idp_principal_tenants_pkey"), + ); + }); +}); diff --git a/src/server/self-hosted/auth/idp-test-support.ts b/src/server/self-hosted/auth/idp-test-support.ts index 8bed65df..27b062a6 100644 --- a/src/server/self-hosted/auth/idp-test-support.ts +++ b/src/server/self-hosted/auth/idp-test-support.ts @@ -47,6 +47,8 @@ export interface TestIdpTokenInput { ttlSeconds?: number; nowMs?: number; jti?: string; + /** Optional not-before, epoch seconds (the IdP may emit it; emails must read it). */ + nbf?: number; /** Override the JWS header (bad-alg / missing-kid fixtures). */ header?: Record; } @@ -67,6 +69,7 @@ export function signTestIdpToken(key: TestIdpKey, input: TestIdpTokenInput = {}) iat: nowSec, exp: nowSec + (input.ttlSeconds ?? 3600), jti: input.jti ?? "jti-test-1", + ...(input.nbf !== undefined ? { nbf: input.nbf } : {}), }; const header = input.header ?? { alg: IDP_TOKEN_ALG, kid: key.kid, typ: IDP_TOKEN_TYPE }; const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`; diff --git a/src/server/self-hosted/auth/idp-token.test.ts b/src/server/self-hosted/auth/idp-token.test.ts index abeede79..532ef876 100644 --- a/src/server/self-hosted/auth/idp-token.test.ts +++ b/src/server/self-hosted/auth/idp-token.test.ts @@ -66,13 +66,17 @@ describe("verifyIdpToken", () => { }); it("refuses a signed JWS that is not explicitly an access token", () => { + // Two layers agree, and both are pinned: the structural sniff does not even + // CLASS a non-`at+jwt` JWS as this credential (typ decides the class, not the + // signature algorithm), and a direct verify call refuses it TYPED — the token + // parses fine, it is the declared type that is wrong. const wrongType = signTestIdpToken(key, { header: { alg: "EdDSA", kid: key.kid, typ: "JWT" } }); - expect(looksLikeIdpToken(wrongType.token)).toBe(true); - expect(verify(wrongType.token)).toEqual({ ok: false, reason: "malformed" }); + expect(looksLikeIdpToken(wrongType.token)).toBe(false); + expect(verify(wrongType.token)).toEqual({ ok: false, reason: "unsupported_typ" }); const missingType = signTestIdpToken(key, { header: { alg: "EdDSA", kid: key.kid } }); - expect(looksLikeIdpToken(missingType.token)).toBe(true); - expect(verify(missingType.token)).toEqual({ ok: false, reason: "malformed" }); + expect(looksLikeIdpToken(missingType.token)).toBe(false); + expect(verify(missingType.token)).toEqual({ ok: false, reason: "unsupported_typ" }); }); it("refuses a header without kid (missing_kid)", () => { diff --git a/src/server/self-hosted/auth/idp-token.ts b/src/server/self-hosted/auth/idp-token.ts index 9a88580e..1e16d547 100644 --- a/src/server/self-hosted/auth/idp-token.ts +++ b/src/server/self-hosted/auth/idp-token.ts @@ -29,6 +29,15 @@ export const IDP_JWKS_URL_ENV = "EMAILS_IDP_JWKS_URL"; /** Optional override for the JWKS cache TTL (seconds). */ export const IDP_JWKS_CACHE_SECONDS_ENV = "EMAILS_IDP_JWKS_CACHE_SECONDS"; export const DEFAULT_IDP_JWKS_CACHE_SECONDS = 300; +/** + * Optional override for the maximum JWKS staleness (seconds). Stale-if-error + * keeps the last-good key set through TRANSIENT fetch failures, but only up to + * this ceiling: past it the cache is discarded and verification fails with the + * typed 503 the class promises, because keys that have been unverifiable for + * this long are no longer evidence of anything. Must be >= the cache TTL. + */ +export const IDP_JWKS_MAX_STALE_SECONDS_ENV = "EMAILS_IDP_JWKS_MAX_STALE_SECONDS"; +export const DEFAULT_IDP_JWKS_MAX_STALE_SECONDS = 3_600; export interface IdpTokenClaims { /** Fixed idp issuer string (IDP_TOKEN_ISSUER). */ @@ -45,6 +54,8 @@ export interface IdpTokenClaims { scope: string[]; iat: number; exp: number; + /** Optional not-before, epoch seconds; enforced when present. */ + nbf?: number; /** Token id (audit join key between IdP and emails). */ jti: string; } @@ -61,6 +72,7 @@ export interface IdpJwk { export type IdpVerifyFailureReason = | "malformed" | "unsupported_alg" + | "unsupported_typ" | "missing_kid" | "unknown_kid" | "bad_signature" @@ -84,7 +96,12 @@ export function looksLikeIdpToken(token: string): boolean { if (parts.length !== 3) return false; try { const header = JSON.parse(Buffer.from(parts[0]!, "base64url").toString("utf8")); - return header?.typ === IDP_TOKEN_TYPE || header?.alg === IDP_TOKEN_ALG; + // The declared token TYPE decides the class, not the signature algorithm: + // matching on `alg` alone routed every EdDSA JWS the IdP key ever signs — + // refresh tokens, id tokens, anything future — into the access-token + // verifier, which then had to be trusted to notice. The wire contract pins + // typ "at+jwt"; anything else is not this credential class. + return header?.typ === IDP_TOKEN_TYPE; } catch { return false; } @@ -151,8 +168,12 @@ export function verifyIdpToken(token: string, options: VerifyIdpTokenOptions): I // The signing keys are shared by the IdP's token families, so signature, // issuer and audience alone do not prove this is an access token. Pin the // wire type from ADR-0001 to prevent a different, correctly signed JWS from - // being accepted at the bearer-token boundary. - if (header.typ !== IDP_TOKEN_TYPE) return { ok: false, reason: "malformed" }; + // being accepted at the bearer-token boundary — enforced HERE, not only in + // the dispatcher's structural sniff, so a direct verify call cannot accept + // an id/refresh token the same key happens to sign. The refusal is TYPED + // (`unsupported_typ`, mirroring `unsupported_alg`), not folded into + // `malformed`: the token parses fine, it is the declared type that is wrong. + if (header.typ !== IDP_TOKEN_TYPE) return { ok: false, reason: "unsupported_typ" }; if (!header.kid) return { ok: false, reason: "missing_kid" }; const jwk = options.jwks.find((k) => k.kid === header.kid); if (!jwk) return { ok: false, reason: "unknown_kid" }; @@ -172,6 +193,7 @@ export function verifyIdpToken(token: string, options: VerifyIdpTokenOptions): I const leeway = options.leewaySeconds ?? 0; if (typeof claims.exp !== "number" || nowSec > claims.exp + leeway) return { ok: false, reason: "expired" }; if (typeof claims.iat === "number" && claims.iat - leeway > nowSec) return { ok: false, reason: "not_yet_valid" }; + if (typeof claims.nbf === "number" && claims.nbf - leeway > nowSec) return { ok: false, reason: "not_yet_valid" }; if (!validClaims(claims)) return { ok: false, reason: "invalid_claims" }; return { ok: true, claims, kid: header.kid }; } @@ -205,7 +227,8 @@ export type IdpAuthenticateResult = | { ok: false; reason: "jwks_unavailable"; status: 503 }; export interface IdpJwksEvent { - type: "refresh" | "error"; + /** `expired`: the stale-if-error ceiling discarded an unrefreshable key set. */ + type: "refresh" | "error" | "expired"; /** Host only — never a token, never key material. */ urlHost: string; kids?: string[]; @@ -216,6 +239,8 @@ export interface IdpTokenAuthenticatorOptions { jwksUrl: string; expectedAudiences: readonly string[]; cacheSeconds?: number; + /** Hard stale-if-error ceiling (seconds); see IDP_JWKS_MAX_STALE_SECONDS_ENV. */ + maxStaleSeconds?: number; leewaySeconds?: number; /** Test/embedding seam; production uses the built-in fetch. */ fetchJwks?: (url: string) => Promise; @@ -224,6 +249,13 @@ export interface IdpTokenAuthenticatorOptions { onEvent?: (event: IdpJwksEvent) => void; } +/** + * Parse a fetched JWKS document. `null` means the DOCUMENT is malformed (not a + * `{ keys: [...] }` object) — an error, handled stale-if-error. An EMPTY array + * is a well-formed answer meaning "no usable Ed25519 keys exist": that is how + * an IdP fully revokes its signing keys, and it must REPLACE any cached set + * rather than be mistaken for a fetch failure that keeps trusting removed keys. + */ function parseJwksDocument(value: unknown): IdpJwk[] | null { if (!value || typeof value !== "object") return null; const keys = (value as { keys?: unknown }).keys; @@ -236,7 +268,7 @@ function parseJwksDocument(value: unknown): IdpJwk[] | null { out.push({ kty: "OKP", crv: "Ed25519", x: jwk["x"], kid: jwk["kid"], use: "sig", alg: "EdDSA" }); } } - return out.length > 0 ? out : null; + return out; } async function defaultFetchJwks(url: string): Promise { @@ -253,13 +285,17 @@ async function defaultFetchJwks(url: string): Promise { * * Cache policy: TTL-cached; an UNKNOWN kid forces one refetch within a request * (key rotation); a failed refresh falls back to the last good key set (keys - * rotate rarely and signatures still decide) but NEVER to accepting anything — - * with no key set at all the result is a typed 503, fail closed. + * rotate rarely and signatures still decide) but only within the max-staleness + * ceiling, and NEVER to accepting anything — with no key set at all, or a key + * set older than the ceiling, the result is a typed 503, fail closed. An + * EMPTY fetched key set is a revocation and replaces the cache (see + * parseJwksDocument). */ export class IdpTokenAuthenticator { readonly jwksUrl: string; private readonly expectedAudiences: readonly string[]; private readonly cacheMs: number; + private readonly maxStaleMs: number; private readonly leewaySeconds: number | undefined; private readonly fetchJwks: (url: string) => Promise; private readonly nowMs: () => number; @@ -272,6 +308,12 @@ export class IdpTokenAuthenticator { this.jwksUrl = options.jwksUrl; this.expectedAudiences = options.expectedAudiences; this.cacheMs = (options.cacheSeconds ?? DEFAULT_IDP_JWKS_CACHE_SECONDS) * 1_000; + // The ceiling can never undercut the TTL itself, or a fresh fetch would be + // discarded as stale on arrival. + this.maxStaleMs = Math.max( + (options.maxStaleSeconds ?? DEFAULT_IDP_JWKS_MAX_STALE_SECONDS) * 1_000, + this.cacheMs, + ); this.leewaySeconds = options.leewaySeconds; this.fetchJwks = options.fetchJwks ?? defaultFetchJwks; this.nowMs = options.nowMs ?? (() => Date.now()); @@ -292,7 +334,10 @@ export class IdpTokenAuthenticator { this.inflight = (async () => { try { const parsed = parseJwksDocument(await this.fetchJwks(this.jwksUrl)); - if (!parsed) throw new Error("JWKS document has no usable Ed25519 keys"); + if (!parsed) throw new Error("JWKS document is malformed (no keys array)"); + // A well-formed document ALWAYS replaces the cache — including an + // empty one, which is the IdP revoking every signing key. Only a + // fetch/parse failure falls through to stale-if-error below. this.keys = parsed; this.fetchedAtMs = this.nowMs(); this.onEvent?.({ type: "refresh", urlHost: this.urlHost(), kids: parsed.map((k) => k.kid) }); @@ -302,7 +347,8 @@ export class IdpTokenAuthenticator { urlHost: this.urlHost(), error: error instanceof Error ? error.message : String(error), }); - // Keep any previous key set (stale-if-error); callers fail typed when none exists. + // Keep any previous key set (stale-if-error, bounded by the ceiling + // in currentKeys); callers fail typed when none survives. } finally { this.inflight = null; } @@ -311,9 +357,22 @@ export class IdpTokenAuthenticator { await this.inflight; } + /** + * Enforce the max-staleness ceiling: a key set that could not be refreshed + * for longer than the ceiling is discarded, so authentication fails with the + * typed 503 instead of trusting keys the IdP may long since have rotated. + */ + private enforceStalenessCeiling(): void { + if (this.keys !== null && this.nowMs() - this.fetchedAtMs >= this.maxStaleMs) { + this.keys = null; + this.onEvent?.({ type: "expired", urlHost: this.urlHost() }); + } + } + private async currentKeys(): Promise { const fresh = this.keys !== null && this.nowMs() - this.fetchedAtMs < this.cacheMs; if (!fresh) await this.refresh(); + this.enforceStalenessCeiling(); return this.keys; } @@ -329,6 +388,7 @@ export class IdpTokenAuthenticator { if (!result.ok && result.reason === "unknown_kid") { // Possible key rotation since the last fetch: refresh once and retry. await this.refresh(); + this.enforceStalenessCeiling(); keys = this.keys; if (!keys) return { ok: false, reason: "jwks_unavailable", status: 503 }; result = verifyIdpToken(token, { @@ -377,10 +437,21 @@ export function buildIdpAuthenticatorFromEnv( if (cacheSeconds !== undefined && (!Number.isFinite(cacheSeconds) || cacheSeconds <= 0)) { throw new Error(`${IDP_JWKS_CACHE_SECONDS_ENV} must be a positive number of seconds.`); } + const maxStaleRaw = env[IDP_JWKS_MAX_STALE_SECONDS_ENV]?.trim(); + const maxStaleSeconds = maxStaleRaw ? Number(maxStaleRaw) : undefined; + if (maxStaleSeconds !== undefined) { + const floor = cacheSeconds ?? DEFAULT_IDP_JWKS_CACHE_SECONDS; + if (!Number.isFinite(maxStaleSeconds) || maxStaleSeconds < floor) { + throw new Error( + `${IDP_JWKS_MAX_STALE_SECONDS_ENV} must be a number of seconds >= the JWKS cache TTL (${floor}).`, + ); + } + } return new IdpTokenAuthenticator({ jwksUrl: url, expectedAudiences, ...(cacheSeconds !== undefined ? { cacheSeconds } : {}), + ...(maxStaleSeconds !== undefined ? { maxStaleSeconds } : {}), ...(onEvent ? { onEvent } : {}), }); } diff --git a/src/server/self-hosted/auth/service.ts b/src/server/self-hosted/auth/service.ts index a10fa923..62875945 100644 --- a/src/server/self-hosted/auth/service.ts +++ b/src/server/self-hosted/auth/service.ts @@ -15,7 +15,7 @@ // humans. Enumeration is avoided (generic messages, constant-time login). import type { ApiKeyVerifier } from "@hasna/contracts/auth"; -import { extractToken, hasAllScopes } from "@hasna/contracts/auth"; +import { extractToken, hasAllScopes, tenantIdsEqual } from "@hasna/contracts/auth"; import { looksLikeIdpToken, normalizeIdpScopes, @@ -171,6 +171,14 @@ export async function resolveRequestContext( } const tenantId = await deps.authStore.getApiKeyTenant(decision.principal.kid); if (!tenantId) return fail(403, "api key is not bound to a tenant", "no_tenant"); + // The DB mapping remains the AUTHORITY for which tenant the key acts in + // (a client-presented claim must never pick the tenant) — but when the key + // carries the signed, tamper-evident `tid` claim, drift between what it + // was minted for and what it resolves to is a refusal, not a silent pass + // into the other organization. Untenanted (pre-tid) keys are unaffected. + if (decision.principal.tid && !tenantIdsEqual(decision.principal.tid, tenantId)) { + return fail(403, "api key's signed tenant does not match its local tenant binding", "tenant_mismatch"); + } return { ok: true, ctx: { @@ -266,18 +274,40 @@ async function resolveIdpContext( const { claims, kid } = verified; const ids = { sub: claims.sub, tid: claims.tid, jti: claims.jti, kid }; - const mapping = await deps.authStore.getIdpPrincipalTenant(claims.sub); - if (!mapping) { + // Since the (sub, tenant_id) keying, one sub may hold several grants. Refuse + // with the MOST SPECIFIC typed reason: no rows at all, every row pinned to a + // different IdP tenant, every candidate revoked, or — fail closed rather + // than pick silently — more than one live candidate. + const mappings = await deps.authStore.listIdpPrincipalTenantsForSub(claims.sub); + if (mappings.length === 0) { audit("deny", 403, "no_tenant", ids); return fail(403, "idp principal is not mapped to a tenant", "no_tenant"); } - if (mapping.revokedAt) { + const tidMatched = mappings.filter((m) => !m.idpTid || m.idpTid === claims.tid); + if (tidMatched.length === 0) { + audit("deny", 403, "idp_tenant_mismatch", ids); + return fail(403, "idp token tenant does not match the granted mapping", "idp_tenant_mismatch"); + } + const live = tidMatched.filter((m) => !m.revokedAt); + if (live.length === 0) { audit("deny", 403, "idp_principal_revoked", ids); return fail(403, "idp principal access has been revoked", "idp_principal_revoked"); } - if (mapping.idpTid && mapping.idpTid !== claims.tid) { - audit("deny", 403, "idp_tenant_mismatch", ids); - return fail(403, "idp token tenant does not match the granted mapping", "idp_tenant_mismatch"); + if (live.length > 1) { + audit("deny", 403, "idp_grant_ambiguous", ids); + return fail( + 403, + "idp principal holds more than one live tenant grant; revoke all but one for this IdP tenant", + "idp_grant_ambiguous", + ); + } + const mapping = live[0]!; + // The grant pins WHAT KIND of principal it was made for; a token asserting a + // different kind is a different identity wearing the same sub. The column + // exists precisely to be compared. + if (mapping.principalType !== claims.pt) { + audit("deny", 403, "idp_principal_type_mismatch", ids); + return fail(403, "idp token principal type does not match the granted mapping", "idp_principal_type_mismatch"); } const scopes = normalizeIdpScopes(claims.scope); @@ -404,7 +434,8 @@ export async function handleAuthRoutes( path === "/v1/tenants" || path.startsWith("/v1/tenants/") || path.startsWith("/v1/memberships/") || path === "/v1/invites/accept" || - path === "/v1/keys" || path.startsWith("/v1/keys/"); + path === "/v1/keys" || path.startsWith("/v1/keys/") || + path === "/v1/idp-principals" || path.startsWith("/v1/idp-principals/"); if (!isAuthPath) return null; try { @@ -502,6 +533,30 @@ export async function handleAuthRoutes( return json(405, { error: "method not allowed" }); } + // IdP-principal federation grants (ADR-0001/0002 operator surface). + if (path === "/v1/idp-principals") { + if (method === "GET") return await handleListIdpPrincipals(deps, req, url); + if (method === "POST") return await handleGrantIdpPrincipal(deps, req, url); + return json(405, { error: "method not allowed" }); + } + // Matched BEFORE the bare `/v1/idp-principals/{sub}` matcher so the verbs + // are never read as a sub. + const idpRevokeMatch = path.match(/^\/v1\/idp-principals\/([^/]+)\/revoke$/); + if (idpRevokeMatch) { + if (method === "POST") return await handleRevokeIdpPrincipal(deps, req, url, decodeURIComponent(idpRevokeMatch[1]!)); + return json(405, { error: "method not allowed" }); + } + const idpRestoreMatch = path.match(/^\/v1\/idp-principals\/([^/]+)\/restore$/); + if (idpRestoreMatch) { + if (method === "POST") return await handleRestoreIdpPrincipal(deps, req, url, decodeURIComponent(idpRestoreMatch[1]!)); + return json(405, { error: "method not allowed" }); + } + const idpPrincipalMatch = path.match(/^\/v1\/idp-principals\/([^/]+)$/); + if (idpPrincipalMatch) { + if (method === "DELETE") return await handleRevokeIdpPrincipal(deps, req, url, decodeURIComponent(idpPrincipalMatch[1]!)); + return json(405, { error: "method not allowed" }); + } + return json(404, { error: "not found" }); } catch (err) { if (err instanceof SyntaxError || (err instanceof Error && err.message.includes("JSON"))) { @@ -1058,9 +1113,12 @@ async function handleGetTenant(deps: AuthServiceDeps, req: Request, url: URL, te const resolved = await resolveRequestContext(deps, req, url, ["emails:read"]); if (!resolved.ok) return resolved.response; const membership = await callerMembership(deps, resolved.ctx, tenantId); - // A key may read its own tenant; a user must be a member. - const keyOwnsTenant = resolved.ctx.principalType === "apikey" && resolved.ctx.tenantId === tenantId; - if (!membership && !keyOwnsTenant) return json(404, { error: "organization not found", reason: "not_found" }); + // A non-user principal (API key or IdP principal) may read ITS OWN tenant — + // the id /v1/me just returned; a user must be a member. + const principalOwnsTenant = + (resolved.ctx.principalType === "apikey" || resolved.ctx.principalType === "idp") && + resolved.ctx.tenantId === tenantId; + if (!membership && !principalOwnsTenant) return json(404, { error: "organization not found", reason: "not_found" }); const tenant = await deps.authStore.getTenantById(tenantId); if (!tenant) return json(404, { error: "organization not found", reason: "not_found" }); return json(200, { tenant: toPublicTenant(tenant), role: membership?.role }); @@ -1265,6 +1323,108 @@ async function handleRevokeKey(deps: AuthServiceDeps, req: Request, url: URL, ki return done ? json(200, { revoked: true, kid }) : json(404, { error: "key not found" }); } +// ---- handlers: IdP-principal federation grants (ADR-0001/0002) --------------- + +/** + * Resolve + operator-gate an /v1/idp-principals request. Granting, revoking, or + * restoring a federation mapping changes WHO may act inside the tenant — the + * same privilege boundary as send-key minting — so bare `emails:write` is never + * sufficient: interactive callers need owner/admin, automation the wildcard. + * The tenant is ALWAYS the caller's resolved tenant, never a parameter. + */ +async function requireIdpPrincipalOperator( + deps: AuthServiceDeps, + req: Request, + url: URL, + write: boolean, +): Promise { + const resolved = await resolveRequestContext(deps, req, url, [write ? "emails:write" : "emails:read"]); + if (!resolved.ok) return resolved; + if (!isTenantOperator(resolved.ctx)) { + return { + ok: false, + response: json(403, { + error: "managing idp principals requires a tenant owner, admin, or operator API key", + reason: "operator_required", + }), + }; + } + return resolved; +} + +async function handleListIdpPrincipals(deps: AuthServiceDeps, req: Request, url: URL): Promise { + const resolved = await requireIdpPrincipalOperator(deps, req, url, false); + if (!resolved.ok) return resolved.response; + const grants = await deps.authStore.listIdpPrincipalTenants(resolved.ctx.tenantId); + return json(200, { + idp_principals: grants.map((grant) => ({ + sub: grant.sub, + tenant_id: grant.tenantId, + idp_tid: grant.idpTid, + principal_type: grant.principalType, + note: grant.note, + created_at: grant.createdAt, + revoked_at: grant.revokedAt, + })), + }); +} + +async function handleGrantIdpPrincipal(deps: AuthServiceDeps, req: Request, url: URL): Promise { + const resolved = await requireIdpPrincipalOperator(deps, req, url, true); + if (!resolved.ok) return resolved.response; + const body = await readJsonBody(req); + const sub = str(body.sub); + if (!sub || sub.length > 200) { + return json(400, { error: "sub is required (the IdP principal id, at most 200 characters)" }); + } + const principalTypeRaw = str(body.principal_type) || "service"; + if (principalTypeRaw !== "user" && principalTypeRaw !== "service") { + return json(400, { error: "principal_type must be 'user' or 'service'" }); + } + const idpTid = str(body.idp_tid) || null; + const note = str(body.note) || null; + const grant = await deps.authStore.upsertIdpPrincipalTenant({ + sub, + tenantId: resolved.ctx.tenantId, + idpTid, + principalType: principalTypeRaw, + note, + createdByUserId: resolved.ctx.userId ?? null, + }); + if (!grant) return json(500, { error: "the grant could not be persisted" }); + return json(201, { + grant: { + sub: grant.sub, + tenant_id: grant.tenantId, + idp_tid: grant.idpTid, + principal_type: grant.principalType, + revoked_at: grant.revokedAt, + }, + // A re-grant NEVER lifts the kill switch; say so instead of implying access. + ...(grant.revokedAt + ? { warning: "this grant is revoked; POST /v1/idp-principals/{sub}/restore to deliberately lift the kill switch" } + : {}), + }); +} + +async function handleRevokeIdpPrincipal(deps: AuthServiceDeps, req: Request, url: URL, sub: string): Promise { + const resolved = await requireIdpPrincipalOperator(deps, req, url, true); + if (!resolved.ok) return resolved.response; + const revoked = await deps.authStore.revokeIdpPrincipalTenant(sub, resolved.ctx.tenantId); + return revoked + ? json(200, { revoked: true, sub }) + : json(404, { error: "no live idp principal grant for that sub in this organization", reason: "not_found" }); +} + +async function handleRestoreIdpPrincipal(deps: AuthServiceDeps, req: Request, url: URL, sub: string): Promise { + const resolved = await requireIdpPrincipalOperator(deps, req, url, true); + if (!resolved.ok) return resolved.response; + const restored = await deps.authStore.restoreIdpPrincipalTenant(sub, resolved.ctx.tenantId); + return restored + ? json(200, { restored: true, sub }) + : json(404, { error: "no revoked idp principal grant for that sub in this organization", reason: "not_found" }); +} + function retryLater(seconds: number): Response { return new Response(JSON.stringify({ error: "too many requests", reason: "rate_limited", retry_after: seconds }), { status: 429, diff --git a/src/server/self-hosted/auth/store.ts b/src/server/self-hosted/auth/store.ts index 54b467ba..314a2cb2 100644 --- a/src/server/self-hosted/auth/store.ts +++ b/src/server/self-hosted/auth/store.ts @@ -234,14 +234,16 @@ export class AuthStore { } /** - * Resolve a verified idp token's `sub` to its mapping row (ADR-0001 Phase 1). + * Resolve a verified idp token's `sub` to its mapping rows (ADR-0001 Phase 1). * Mirrors getApiKeyTenant's fail-closed tenant-status join: a suspended tenant - * locks out its idp principals too. The row is returned WITH `revoked_at` + * locks out its idp principals too. Rows are returned WITH `revoked_at` * and `idp_tid` so the caller can refuse with a precise, typed reason - * (revoked mapping vs IdP-tenant mismatch) instead of a generic miss. + * (revoked mapping vs IdP-tenant mismatch vs ambiguity) instead of a generic + * miss. Since the (sub, tenant_id) keying, one sub may hold several grants; + * the ordering is pinned so any caller-side selection is deterministic. */ - async getIdpPrincipalTenant(sub: string): Promise { - const row = await this.client.get<{ + async listIdpPrincipalTenantsForSub(sub: string): Promise { + const rows = await this.client.many<{ sub: string; tenant_id: string; idp_tid: string | null; @@ -251,20 +253,62 @@ export class AuthStore { `SELECT fpt.sub, fpt.tenant_id, fpt.idp_tid, fpt.principal_type, fpt.revoked_at FROM idp_principal_tenants fpt JOIN tenants t ON t.id = fpt.tenant_id - WHERE fpt.sub = $1 AND t.status = 'active'`, + WHERE fpt.sub = $1 AND t.status = 'active' + ORDER BY fpt.created_at, fpt.tenant_id`, [sub], ); - if (!row) return null; - return { + return rows.map((row) => ({ sub: row.sub, tenantId: row.tenant_id, idpTid: row.idp_tid, principalType: row.principal_type === "user" ? "user" : "service", revokedAt: row.revoked_at, - }; + })); } - /** Create (or re-point) an IdP-principal mapping. Explicit grant, never inferred. */ + /** + * List every IdP-principal grant of ONE tenant (the operator surface's read). + * Revoked grants are included — the kill-switch state must be visible, not + * disappear from the list the moment it is thrown. + */ + async listIdpPrincipalTenants(tenantId: string): Promise> { + const rows = await this.client.many<{ + sub: string; + tenant_id: string; + idp_tid: string | null; + principal_type: string; + note: string | null; + created_at: string; + revoked_at: string | null; + }>( + `SELECT sub, tenant_id, idp_tid, principal_type, note, created_at, revoked_at + FROM idp_principal_tenants + WHERE tenant_id = $1 + ORDER BY created_at, sub`, + [tenantId], + ); + return rows.map((row) => ({ + sub: row.sub, + tenantId: row.tenant_id, + idpTid: row.idp_tid, + principalType: row.principal_type === "user" ? "user" : "service", + note: row.note, + createdAt: row.created_at, + revokedAt: row.revoked_at, + })); + } + + /** + * Create or refresh ONE (sub, tenant) grant. Explicit, never inferred, and + * deliberately incapable of resurrecting a revoked grant: the conflict arm + * updates the descriptive columns only and leaves `revoked_at` untouched — + * un-revoking is restoreIdpPrincipalTenant, a separate deliberate act. + * Returns the persisted row so a caller can SEE it re-granted a revoked + * mapping without effect. + */ async upsertIdpPrincipalTenant(input: { sub: string; tenantId: string; @@ -272,17 +316,22 @@ export class AuthStore { principalType?: "user" | "service"; note?: string | null; createdByUserId?: string | null; - }): Promise { - await this.client.execute( + }): Promise { + const row = await this.client.get<{ + sub: string; + tenant_id: string; + idp_tid: string | null; + principal_type: string; + revoked_at: string | null; + }>( `INSERT INTO idp_principal_tenants (sub, tenant_id, idp_tid, principal_type, note, created_by_user_id) VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (sub) DO UPDATE SET - tenant_id = EXCLUDED.tenant_id, + ON CONFLICT (sub, tenant_id) DO UPDATE SET idp_tid = EXCLUDED.idp_tid, principal_type = EXCLUDED.principal_type, note = EXCLUDED.note, - created_by_user_id = EXCLUDED.created_by_user_id, - revoked_at = NULL`, + created_by_user_id = EXCLUDED.created_by_user_id + RETURNING sub, tenant_id, idp_tid, principal_type, revoked_at`, [ input.sub, input.tenantId, @@ -292,13 +341,45 @@ export class AuthStore { input.createdByUserId ?? null, ], ); + if (!row) return null; + return { + sub: row.sub, + tenantId: row.tenant_id, + idpTid: row.idp_tid, + principalType: row.principal_type === "user" ? "user" : "service", + revokedAt: row.revoked_at, + }; + } + + /** + * Emails-side immediate kill switch for an IdP principal (ADR-0002 step 5). + * With a tenant id, exactly that grant is revoked; without one, EVERY live + * grant the sub holds is revoked — the incident path, one call. + */ + async revokeIdpPrincipalTenant(sub: string, tenantId?: string): Promise { + const result = tenantId + ? await this.client.query( + `UPDATE idp_principal_tenants SET revoked_at = now() + WHERE sub = $1 AND tenant_id = $2 AND revoked_at IS NULL`, + [sub, tenantId], + ) + : await this.client.query( + `UPDATE idp_principal_tenants SET revoked_at = now() + WHERE sub = $1 AND revoked_at IS NULL`, + [sub], + ); + return result.rowCount > 0; } - /** Emails-side immediate kill switch for an IdP principal (ADR-0002 step 5). */ - async revokeIdpPrincipalTenant(sub: string): Promise { + /** + * Deliberately lift the kill switch on ONE (sub, tenant) grant. This is the + * only operation that clears `revoked_at` — a re-grant does not. + */ + async restoreIdpPrincipalTenant(sub: string, tenantId: string): Promise { const result = await this.client.query( - `UPDATE idp_principal_tenants SET revoked_at = now() WHERE sub = $1 AND revoked_at IS NULL`, - [sub], + `UPDATE idp_principal_tenants SET revoked_at = NULL + WHERE sub = $1 AND tenant_id = $2 AND revoked_at IS NOT NULL`, + [sub, tenantId], ); return result.rowCount > 0; } diff --git a/src/server/self-hosted/idp-auth.test.ts b/src/server/self-hosted/idp-auth.test.ts index 384db57c..28baeeae 100644 --- a/src/server/self-hosted/idp-auth.test.ts +++ b/src/server/self-hosted/idp-auth.test.ts @@ -41,7 +41,11 @@ function fakeClient(mappings: Map): TypedQueryClient { const rows = (await client.many(sql, params)) as never[]; return { rows, rowCount: rows.length }; }, - async many(): Promise { + async many(sql: string, params?: readonly unknown[]): Promise { + if (typeof sql === "string" && sql.includes("idp_principal_tenants")) { + const row = mappings.get(String(params?.[0])); + return (row ? [row] : []) as T[]; + } return [] as T[]; }, async get(sql: string, params?: readonly unknown[]): Promise { diff --git a/src/server/self-hosted/idp-claims-binding.test.ts b/src/server/self-hosted/idp-claims-binding.test.ts new file mode 100644 index 00000000..503fe07c --- /dev/null +++ b/src/server/self-hosted/idp-claims-binding.test.ts @@ -0,0 +1,287 @@ +// Full binding of IdP token claims to the mapping and to downstream surfaces. +// +// The idp principal class was added to the resolver but not to everything +// downstream of it. Four gaps, one cause, pinned here: +// +// 1. `pt` (principal type) is signed in the token and stored on the grant — +// the column exists precisely to be compared, and never was: a 'user' +// token was accepted against a 'service' grant and vice versa. +// 2. The pinned wire contract says header typ 'at+jwt'; verification never +// read `typ` (any EdDSA JWS the IdP key signed verified here regardless +// of its intended token type), and `nbf` was never read either. +// 3. GET /v1/tenants/{id} computed own-tenant access only for the apikey +// class, so an IdP principal reading the very tenant id /v1/me just +// returned got 404. +// 4. The send-intent reconciliation ledger recorded IdP principals as the +// literal 'apikey:unknown' — the wrong credential class, sub dropped. +// +// Hermetic: fake query client, stubbed store methods, no Postgres, no network. + +import { describe, expect, it } from "bun:test"; +import { verifyApiKey } from "@hasna/contracts/auth"; +import type { TypedQueryClient } from "../../storage-kit/index.js"; +import { emailsSelfHostedMigrations } from "./migrations.js"; +import { handleSelfHostedRequest, type SelfHostedServiceDeps } from "./service.js"; +import { + IdpTokenAuthenticator, + looksLikeIdpToken, + verifyIdpToken, +} from "./auth/idp-token.js"; +import { generateTestIdpKey, signTestIdpToken } from "./auth/idp-test-support.js"; +import { resolveRequestContext } from "./auth/service.js"; +import { ALLOWED_EMAIL_DOMAINS_ENV } from "./auth/allowed-email.js"; +import { selfScopedStore, testAuthDeps } from "./auth/test-support.js"; + +const SIGNING_SECRET = "test-signing-secret-do-not-use-in-prod"; +const TENANT_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const IDP_TID = "11111111-2222-3333-4444-555555555555"; + +const key = generateTestIdpKey("kid-bind"); + +interface MappingRow { + sub: string; + tenant_id: string; + idp_tid: string | null; + principal_type: string; + revoked_at: string | null; +} + +function fakeClient(mappings: Map): TypedQueryClient { + const client: TypedQueryClient = { + async query(sql, params) { + const rows = (await client.many(sql, params)) as never[]; + return { rows, rowCount: rows.length }; + }, + async many(sql: string, params?: readonly unknown[]): Promise { + if (typeof sql === "string" && sql.includes("idp_principal_tenants")) { + const row = mappings.get(String(params?.[0])); + return (row ? [row] : []) as T[]; + } + return [] as T[]; + }, + async get(sql: string, params?: readonly unknown[]): Promise { + if (sql.includes("idp_principal_tenants")) { + return (mappings.get(String(params?.[0])) as T | undefined) ?? null; + } + if (sql.includes("FROM tenants WHERE id")) { + return { + id: String(params?.[0]), + slug: "acme", + name: "Acme", + status: "active", + created_at: "", + updated_at: "", + } as unknown as T; + } + return null; + }, + async one(): Promise { + return {} as T; + }, + async execute() {}, + }; + return client; +} + +function idpAuthenticator(): IdpTokenAuthenticator { + return new IdpTokenAuthenticator({ + jwksUrl: "https://idp.example.com/v1/.well-known/jwks.json", + expectedAudiences: ["emails", "mailery"], + fetchJwks: async () => ({ keys: [key.publicJwk] }), + }); +} + +function deps(mappings: Map): SelfHostedServiceDeps { + const client = fakeClient(mappings); + const d: SelfHostedServiceDeps = { + client, + store: selfScopedStore(client), + verifier: verifyApiKey({ app: "emails", signingSecret: SIGNING_SECRET }), + sender: { provider: "ses", send: async () => "provider-message-id" }, + migrations: emailsSelfHostedMigrations(), + version: "9.9.9", + ...testAuthDeps(client, SIGNING_SECRET), + idpAuthenticator: idpAuthenticator(), + }; + d.env = { ...d.env, [ALLOWED_EMAIL_DOMAINS_ENV]: "example.com" }; + return d; +} + +function mappingRow(overrides: Partial = {}): MappingRow { + return { + sub: "sp-agent-1", + tenant_id: TENANT_ID, + idp_tid: IDP_TID, + principal_type: "service", + revoked_at: null, + ...overrides, + }; +} + +function get(path: string, token: string): Request { + return new Request(`http://self-hosted.test${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +async function resolve(d: SelfHostedServiceDeps, token: string) { + const url = new URL("http://self-hosted.test/v1/messages"); + return resolveRequestContext(d, get("/v1/messages", token), url, ["emails:read"]); +} + +async function reason(response: Response): Promise<{ status: number; reason: string | undefined }> { + const body = (await response.json()) as { reason?: string }; + return { status: response.status, reason: body.reason }; +} + +describe("pt is bound to the grant's principal_type", () => { + it("refuses a 'user' token against a 'service' grant, typed", async () => { + const d = deps(new Map([["sp-agent-1", mappingRow({ principal_type: "service" })]])); + const { token } = signTestIdpToken(key, { sub: "sp-agent-1", tid: IDP_TID, pt: "user" }); + const result = await resolve(d, token); + if (result.ok) throw new Error("expected refusal"); + expect(await reason(result.response)).toEqual({ status: 403, reason: "idp_principal_type_mismatch" }); + }); + + it("refuses a 'service' token against a 'user' grant, and accepts the matching type", async () => { + const d = deps(new Map([["sp-agent-1", mappingRow({ principal_type: "user" })]])); + const serviceToken = signTestIdpToken(key, { sub: "sp-agent-1", tid: IDP_TID, pt: "service" }).token; + const denied = await resolve(d, serviceToken); + if (denied.ok) throw new Error("expected refusal"); + expect((await denied.response.json() as { reason?: string }).reason).toBe("idp_principal_type_mismatch"); + + const userToken = signTestIdpToken(key, { sub: "sp-agent-1", tid: IDP_TID, pt: "user" }).token; + expect((await resolve(d, userToken)).ok).toBe(true); + }); +}); + +describe("header typ and nbf are validated", () => { + it("verifyIdpToken refuses a non-access-token typ, typed", () => { + const { token } = signTestIdpToken(key, { header: { alg: "EdDSA", kid: key.kid, typ: "JWT" } }); + expect( + verifyIdpToken(token, { jwks: [key.publicJwk], expectedAudiences: ["emails"] }), + ).toEqual({ ok: false, reason: "unsupported_typ" }); + }); + + it("an EdDSA JWS without the at+jwt typ is NOT accepted through the resolver", async () => { + const d = deps(new Map([["sp-agent-1", mappingRow()]])); + const { token } = signTestIdpToken(key, { + sub: "sp-agent-1", + tid: IDP_TID, + header: { alg: "EdDSA", kid: key.kid, typ: "JWT" }, + }); + const result = await resolve(d, token); + if (result.ok) throw new Error("expected refusal — a non-at+jwt EdDSA JWS must never authenticate"); + expect(result.response.status).toBe(401); + }); + + it("looksLikeIdpToken keys the class on typ, not on the signature algorithm alone", () => { + const declared = signTestIdpToken(key).token; + expect(looksLikeIdpToken(declared)).toBe(true); + const undeclared = signTestIdpToken(key, { header: { alg: "EdDSA", kid: key.kid } }).token; + expect(looksLikeIdpToken(undeclared)).toBe(false); + }); + + it("refuses a token whose nbf lies in the future (not_yet_valid), honoring leeway", () => { + const now = Date.now(); + const { token } = signTestIdpToken(key, { nowMs: now, nbf: Math.floor(now / 1000) + 600 }); + expect( + verifyIdpToken(token, { jwks: [key.publicJwk], expectedAudiences: ["emails"], nowMs: now }), + ).toEqual({ ok: false, reason: "not_yet_valid" }); + expect( + verifyIdpToken(token, { + jwks: [key.publicJwk], + expectedAudiences: ["emails"], + nowMs: now, + leewaySeconds: 900, + }).ok, + ).toBe(true); + }); +}); + +describe("GET /v1/tenants/{id} — an IdP principal can read its own tenant", () => { + it("returns the tenant /v1/me just named instead of 404", async () => { + const d = deps(new Map([["sp-agent-1", mappingRow()]])); + const { token } = signTestIdpToken(key, { sub: "sp-agent-1", tid: IDP_TID, scope: ["emails:read"] }); + const response = await handleSelfHostedRequest(d, get(`/v1/tenants/${TENANT_ID}`, token)); + expect(response.status).toBe(200); + const body = (await response.json()) as { tenant?: { id?: string } }; + expect(body.tenant?.id).toBe(TENANT_ID); + }); + + it("still refuses a FOREIGN tenant id for an IdP principal", async () => { + const d = deps(new Map([["sp-agent-1", mappingRow()]])); + const { token } = signTestIdpToken(key, { sub: "sp-agent-1", tid: IDP_TID, scope: ["emails:read"] }); + const response = await handleSelfHostedRequest( + d, + get("/v1/tenants/99999999-0000-0000-0000-000000000000", token), + ); + expect(response.status).toBe(404); + }); +}); + +describe("send-intent reconciliation names the IdP principal", () => { + it("records resolvedBy as idp:, never apikey:unknown", async () => { + const d = deps(new Map([["sp-agent-1", mappingRow()]])); + const uncertain = { + id: "22222222-2222-4222-8222-222222222222", + direction: "outbound", + from_addr: "agent@example.com", + to_addrs: ["user@example.com"], + cc_addrs: [], + subject: "s", + body_text: null, + body_html: null, + status: "queued", + provider_message_id: null, + message_id: null, + in_reply_to: null, + received_at: null, + is_read: false, + is_starred: false, + labels: [], + headers: {}, + attachments: [], + source_id: null, + idempotency_key: "k", + send_payload_hash: null, + send_state: "uncertain", + send_started_at: null, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }; + const resolutions: Array<{ resolvedBy?: string | null }> = []; + const store = d.store as unknown as Record; + store["resolveMessageId"] = async (id: string) => ({ id }); + store["getMessage"] = async () => uncertain; + store["reconcileUncertainSendIntent"] = async ( + _id: string, + resolution: { resolvedBy?: string | null }, + ) => { + resolutions.push(resolution); + return { ...uncertain, send_state: "sent", provider_message_id: "prov-1" }; + }; + + const { token } = signTestIdpToken(key, { + sub: "sp-agent-1", + tid: IDP_TID, + scope: ["emails:read", "emails:write"], + }); + const response = await handleSelfHostedRequest( + d, + new Request("http://self-hosted.test/v1/messages/send-intents/reconcile", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + message_id: uncertain.id, + outcome: "sent", + provider_message_id: "prov-1", + evidence: "provider dashboard message prov-1", + }), + }), + ); + expect(response.status).toBe(200); + expect(resolutions).toHaveLength(1); + expect(resolutions[0]!.resolvedBy).toBe("idp:sp-agent-1"); + }); +}); diff --git a/src/server/self-hosted/idp-principals-surface.test.ts b/src/server/self-hosted/idp-principals-surface.test.ts new file mode 100644 index 00000000..389d5f9e --- /dev/null +++ b/src/server/self-hosted/idp-principals-surface.test.ts @@ -0,0 +1,281 @@ +// Operator surface for IdP-principal federation grants (ADR-0001/0002). +// +// The federation slice was inert: upsertIdpPrincipalTenant and +// revokeIdpPrincipalTenant had zero non-test callers, so no grant row could +// exist without hand SQL against production — and revoked_at, the ONLY +// revocation emails can enforce inside a token's ≤24h life, had no operator +// surface either. These routes make a grant auditable and a revocation +// one call: +// +// GET /v1/idp-principals list this tenant's grants +// POST /v1/idp-principals grant { sub, ... } -> this tenant +// POST /v1/idp-principals/{sub}/revoke throw the kill switch +// DELETE /v1/idp-principals/{sub} same as revoke +// POST /v1/idp-principals/{sub}/restore deliberately lift the kill switch +// +// All are privilege-GRANTING operations (they change WHO may act in the +// tenant), so — exactly like send-key minting — they require a tenant +// owner/admin or the wildcard operator scope, never bare emails:write. The +// tenant is ALWAYS the caller's resolved tenant, never a parameter. +// +// Hermetic: fake query client + patched auth store, no Postgres. + +import { describe, expect, test } from "bun:test"; +import { mintApiKey, verifyApiKey } from "@hasna/contracts/auth"; +import type { TypedQueryClient } from "../../storage-kit/index.js"; +import { emailsSelfHostedMigrations } from "./migrations.js"; +import { handleSelfHostedRequest, type SelfHostedServiceDeps } from "./service.js"; +import { DEFAULT_TENANT_ID } from "./migrations.js"; +import { selfScopedStore, testAuthDeps } from "./auth/test-support.js"; + +const SIGNING_SECRET = "test-signing-secret-do-not-use-in-prod"; + +function fakeClient(): TypedQueryClient { + const client: TypedQueryClient = { + async query() { + return { rows: [] as never[], rowCount: 0 }; + }, + async many(): Promise { + return [] as T[]; + }, + async get(): Promise { + return null; + }, + async one(): Promise { + return {} as T; + }, + async execute() {}, + }; + return client; +} + +type Role = "owner" | "admin" | "member" | "viewer"; + +const SESSION_TOKENS: Record = { + owner: "emss_session_owner", + admin: "emss_session_admin", + member: "emss_session_member", + viewer: "emss_session_viewer", +}; + +interface Spy { + upserts: unknown[]; + revokes: unknown[]; + restores: unknown[]; + lists: unknown[]; +} + +function deps(): { d: SelfHostedServiceDeps; spy: Spy } { + const client = fakeClient(); + const spy: Spy = { upserts: [], revokes: [], restores: [], lists: [] }; + const d: SelfHostedServiceDeps = { + client, + store: selfScopedStore(client), + verifier: verifyApiKey({ app: "emails", signingSecret: SIGNING_SECRET }), + sender: { provider: "ses", send: async () => "provider-message-id" }, + migrations: emailsSelfHostedMigrations(), + version: "9.9.9", + ...testAuthDeps(client, SIGNING_SECRET), + }; + + d.authStore.resolveSession = (async (token: string) => { + const entry = (Object.entries(SESSION_TOKENS) as Array<[Role, string]>).find(([, value]) => value === token); + if (!entry) return null; + return { tenantId: DEFAULT_TENANT_ID, userId: `user-${entry[0]}`, role: entry[0], globalRole: null }; + }) as typeof d.authStore.resolveSession; + + d.authStore.upsertIdpPrincipalTenant = (async (input: unknown) => { + spy.upserts.push(input); + const grant = input as { sub: string; tenantId: string; idpTid?: string | null }; + return { + sub: grant.sub, + tenantId: grant.tenantId, + idpTid: grant.idpTid ?? null, + principalType: "service" as const, + revokedAt: null, + }; + }) as typeof d.authStore.upsertIdpPrincipalTenant; + + d.authStore.revokeIdpPrincipalTenant = (async (sub: string, tenantId?: string) => { + spy.revokes.push({ sub, tenantId }); + return sub === "sp-known"; + }) as typeof d.authStore.revokeIdpPrincipalTenant; + + d.authStore.restoreIdpPrincipalTenant = (async (sub: string, tenantId: string) => { + spy.restores.push({ sub, tenantId }); + return sub === "sp-known"; + }) as typeof d.authStore.restoreIdpPrincipalTenant; + + d.authStore.listIdpPrincipalTenants = (async (tenantId: string) => { + spy.lists.push(tenantId); + return [ + { + sub: "sp-known", + tenantId, + idpTid: "11111111-2222-3333-4444-555555555555", + principalType: "service" as const, + note: "ci agent", + createdAt: "2026-07-01T00:00:00Z", + revokedAt: null, + }, + ]; + }) as typeof d.authStore.listIdpPrincipalTenants; + + return { d, spy }; +} + +function req(method: string, path: string, opts: { token?: string; body?: unknown } = {}): Request { + const headers: Record = { "Content-Type": "application/json" }; + if (opts.token) headers["x-api-key"] = opts.token; + return new Request(`http://svc${path}`, { + method, + headers, + ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + }); +} + +const writeScopedKey = () => mintApiKey({ app: "emails", scopes: ["emails:write"], signingSecret: SIGNING_SECRET }).token; +const operatorKey = () => mintApiKey({ app: "emails", scopes: ["emails:*"], signingSecret: SIGNING_SECRET }).token; + +describe("granting an IdP principal", () => { + test("an owner grants a principal into THEIR resolved tenant (never a body-chosen one)", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals", { + token: SESSION_TOKENS.owner, + body: { + sub: "sp-new-agent", + idp_tid: "11111111-2222-3333-4444-555555555555", + tenant_id: "some-other-tenant-the-caller-typed", + note: "signup", + }, + }), + ); + expect(res?.status).toBe(201); + const body = (await res!.json()) as { grant?: { sub?: string; tenant_id?: string } }; + expect(body.grant?.sub).toBe("sp-new-agent"); + expect(body.grant?.tenant_id).toBe(DEFAULT_TENANT_ID); + expect(spy.upserts).toHaveLength(1); + expect(spy.upserts[0]).toMatchObject({ sub: "sp-new-agent", tenantId: DEFAULT_TENANT_ID }); + }); + + test("a member (bare emails:write) cannot grant — typed operator_required, nothing reaches the store", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals", { token: SESSION_TOKENS.member, body: { sub: "sp-x" } }), + ); + expect(res?.status).toBe(403); + expect(await res!.json()).toMatchObject({ reason: "operator_required" }); + expect(spy.upserts).toEqual([]); + }); + + test("a write-scoped API key cannot grant either; the wildcard operator key can", async () => { + const { d, spy } = deps(); + const denied = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals", { token: writeScopedKey(), body: { sub: "sp-x" } }), + ); + expect(denied?.status).toBe(403); + expect(await denied!.json()).toMatchObject({ reason: "operator_required" }); + expect(spy.upserts).toEqual([]); + + const allowed = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals", { token: operatorKey(), body: { sub: "sp-x" } }), + ); + expect(allowed?.status).toBe(201); + expect(spy.upserts).toHaveLength(1); + }); + + test("a grant without a sub is a 400, not a row", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals", { token: SESSION_TOKENS.owner, body: { note: "no sub" } }), + ); + expect(res?.status).toBe(400); + expect(spy.upserts).toEqual([]); + }); +}); + +describe("listing grants", () => { + test("an admin lists the tenant's grants; a member is refused", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest(d, req("GET", "/v1/idp-principals", { token: SESSION_TOKENS.admin })); + expect(res?.status).toBe(200); + const body = (await res!.json()) as { idp_principals?: Array<{ sub?: string }> }; + expect(body.idp_principals?.[0]?.sub).toBe("sp-known"); + expect(spy.lists).toEqual([DEFAULT_TENANT_ID]); + + const denied = await handleSelfHostedRequest(d, req("GET", "/v1/idp-principals", { token: SESSION_TOKENS.member })); + expect(denied?.status).toBe(403); + }); +}); + +describe("the kill switch is pullable in one call", () => { + test("owner revokes via POST .../revoke; the revocation is tenant-scoped", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals/sp-known/revoke", { token: SESSION_TOKENS.owner }), + ); + expect(res?.status).toBe(200); + expect(await res!.json()).toMatchObject({ revoked: true, sub: "sp-known" }); + expect(spy.revokes).toEqual([{ sub: "sp-known", tenantId: DEFAULT_TENANT_ID }]); + }); + + test("DELETE /v1/idp-principals/{sub} is the same operation", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest( + d, + req("DELETE", "/v1/idp-principals/sp-known", { token: SESSION_TOKENS.owner }), + ); + expect(res?.status).toBe(200); + expect(spy.revokes).toHaveLength(1); + }); + + test("revoking a sub with no live grant in this tenant is a typed 404", async () => { + const { d } = deps(); + const res = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals/sp-unknown/revoke", { token: SESSION_TOKENS.owner }), + ); + expect(res?.status).toBe(404); + expect(await res!.json()).toMatchObject({ reason: "not_found" }); + }); + + test("a member cannot pull the kill switch (privilege boundary, same as granting)", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals/sp-known/revoke", { token: SESSION_TOKENS.member }), + ); + expect(res?.status).toBe(403); + expect(spy.revokes).toEqual([]); + }); +}); + +describe("restore is a separate deliberate act", () => { + test("owner restores a revoked grant explicitly", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals/sp-known/restore", { token: SESSION_TOKENS.owner }), + ); + expect(res?.status).toBe(200); + expect(await res!.json()).toMatchObject({ restored: true, sub: "sp-known" }); + expect(spy.restores).toEqual([{ sub: "sp-known", tenantId: DEFAULT_TENANT_ID }]); + }); + + test("a member cannot restore", async () => { + const { d, spy } = deps(); + const res = await handleSelfHostedRequest( + d, + req("POST", "/v1/idp-principals/sp-known/restore", { token: SESSION_TOKENS.member }), + ); + expect(res?.status).toBe(403); + expect(spy.restores).toEqual([]); + }); +}); diff --git a/src/server/self-hosted/idp-send.test.ts b/src/server/self-hosted/idp-send.test.ts new file mode 100644 index 00000000..ce03cf8d --- /dev/null +++ b/src/server/self-hosted/idp-send.test.ts @@ -0,0 +1,232 @@ +// Outbound send authorization for the IdP credential class (ADR-0001 Phase 1). +// +// An IdP-federated principal granted the ordinary `emails:write` scope must be +// able to POST /v1/messages/send within its mapped tenant, exactly like an API +// key: the send gate's tenant-wide-authority input recognizes the "idp" +// principal class instead of silently computing `false` (which turned every +// federated send into a 403 `send_key_required` with no obtainable send key — +// minting one is operator-gated on the wildcard scope the principal need not +// hold). +// +// Hermetic: fake query client, stubbed tenant-scoped store methods, no +// Postgres, no network. The outbound-policy stub reproduces the REAL +// send-authority branch of the store gate (send_key_required when neither a +// send key nor tenant-wide authority is present), so the assertion is on the +// user-visible outcome, not an internal flag alone. + +import { describe, expect, it } from "bun:test"; +import { verifyApiKey } from "@hasna/contracts/auth"; +import type { TypedQueryClient } from "../../storage-kit/index.js"; +import { emailsSelfHostedMigrations } from "./migrations.js"; +import { handleSelfHostedRequest, type SelfHostedServiceDeps } from "./service.js"; +import { IdpTokenAuthenticator } from "./auth/idp-token.js"; +import { generateTestIdpKey, signTestIdpToken } from "./auth/idp-test-support.js"; +import { ALLOWED_EMAIL_DOMAINS_ENV } from "./auth/allowed-email.js"; +import { selfScopedStore, testAuthDeps } from "./auth/test-support.js"; + +const SIGNING_SECRET = "test-signing-secret-do-not-use-in-prod"; +const TENANT_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const IDP_TID = "11111111-2222-3333-4444-555555555555"; + +const key = generateTestIdpKey("kid-send"); + +interface MappingRow { + sub: string; + tenant_id: string; + idp_tid: string | null; + principal_type: string; + revoked_at: string | null; +} + +function fakeClient(mappings: Map): TypedQueryClient { + const client: TypedQueryClient = { + async query(sql, params) { + const rows = (await client.many(sql, params)) as never[]; + return { rows, rowCount: rows.length }; + }, + async many(sql: string, params?: readonly unknown[]): Promise { + if (typeof sql === "string" && sql.includes("idp_principal_tenants")) { + const row = mappings.get(String(params?.[0])); + return (row ? [row] : []) as T[]; + } + return [] as T[]; + }, + async get(sql: string, params?: readonly unknown[]): Promise { + if (sql.includes("idp_principal_tenants")) { + return (mappings.get(String(params?.[0])) as T | undefined) ?? null; + } + return null; + }, + async one(): Promise { + return {} as T; + }, + async execute() {}, + }; + return client; +} + +function pendingRecord() { + return { + id: "11111111-1111-4111-8111-111111111111", + direction: "outbound", + from_addr: "agent@example.com", + to_addrs: ["user@example.com"], + cc_addrs: [], + subject: "federated send", + body_text: "hello", + body_html: null, + status: "queued", + provider_message_id: null, + message_id: null, + in_reply_to: null, + received_at: null, + is_read: false, + is_starred: false, + labels: [], + headers: {}, + attachments: [], + source_id: null, + idempotency_key: "idp-send-key", + send_payload_hash: "hash", + send_state: "pending", + send_started_at: null, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }; +} + +interface Harness { + deps: SelfHostedServiceDeps; + policyInputs: Array<{ sendKeyToken?: string | null; allowTenantWideSend?: boolean }>; +} + +function harness(mappings: Map): Harness { + const client = fakeClient(mappings); + const deps: SelfHostedServiceDeps = { + client, + store: selfScopedStore(client), + verifier: verifyApiKey({ app: "emails", signingSecret: SIGNING_SECRET }), + sender: { provider: "ses", send: async () => "provider-message-id" }, + migrations: emailsSelfHostedMigrations(), + version: "9.9.9", + ...testAuthDeps(client, SIGNING_SECRET), + idpAuthenticator: new IdpTokenAuthenticator({ + jwksUrl: "https://idp.example.com/v1/.well-known/jwks.json", + expectedAudiences: ["emails", "mailery"], + fetchJwks: async () => ({ keys: [key.publicJwk] }), + }), + }; + deps.env = { ...deps.env, [ALLOWED_EMAIL_DOMAINS_ENV]: "example.com" }; + + const policyInputs: Harness["policyInputs"] = []; + const record = pendingRecord(); + const store = deps.store as unknown as Record; + store["reserveSendIntent"] = async () => ({ record, created: true }); + store["evaluateOutboundPolicy"] = async (input: { + sendKeyToken?: string | null; + allowTenantWideSend?: boolean; + }) => { + policyInputs.push(input); + // The REAL send-authority branch of the store gate (store.ts): a caller + // with neither a send key nor tenant-wide authority is refused, typed. + if (!input.sendKeyToken && !input.allowTenantWideSend) { + return { + allowed: false, + code: "send_key_required", + message: "a sender-scoped send key is required", + status: 403, + }; + } + return { allowed: true }; + }; + store["claimSendIntent"] = async () => ({ ...record, send_state: "sending" }); + store["completeSendIntent"] = async (_id: string, providerMessageId: string) => ({ + ...record, + send_state: "sent", + status: "sent", + provider_message_id: providerMessageId, + }); + store["markSendBlocked"] = async (_id: string, code: string) => ({ + ...record, + send_state: "blocked", + headers: { policy_denial: code }, + }); + return { deps, policyInputs }; +} + +function mappingRow(overrides: Partial = {}): MappingRow { + return { + sub: "sp-agent-1", + tenant_id: TENANT_ID, + idp_tid: IDP_TID, + principal_type: "service", + revoked_at: null, + ...overrides, + }; +} + +function sendRequest(token: string): Request { + return new Request("http://self-hosted.test/v1/messages/send", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "agent@example.com", + to: ["user@example.com"], + subject: "federated send", + text: "hello", + idempotency_key: "idp-send-key", + }), + }); +} + +describe("IdP principal send authorization", () => { + it("lets a mapped IdP principal holding emails:write send WITHOUT a send key or the wildcard", async () => { + const { deps, policyInputs } = harness(new Map([["sp-agent-1", mappingRow()]])); + const { token } = signTestIdpToken(key, { + sub: "sp-agent-1", + tid: IDP_TID, + scope: ["emails:read", "emails:write"], + }); + const response = await handleSelfHostedRequest(deps, sendRequest(token)); + const body = (await response.json()) as Record; + expect({ status: response.status, sent: body["sent"], reason: body["reason"] }).toEqual({ + status: 202, + sent: true, + reason: undefined, + }); + expect(policyInputs).toHaveLength(1); + expect(policyInputs[0]!.allowTenantWideSend).toBe(true); + }); + + it("grants the same authority to the wildcard operator scope", async () => { + const { deps, policyInputs } = harness(new Map([["sp-agent-1", mappingRow()]])); + const { token } = signTestIdpToken(key, { sub: "sp-agent-1", tid: IDP_TID, scope: ["emails:*"] }); + const response = await handleSelfHostedRequest(deps, sendRequest(token)); + expect(response.status).toBe(202); + expect(policyInputs[0]!.allowTenantWideSend).toBe(true); + }); + + it("still refuses a read-only IdP grant at the route scope gate (never reaches the policy)", async () => { + const { deps, policyInputs } = harness(new Map([["sp-agent-1", mappingRow()]])); + const { token } = signTestIdpToken(key, { sub: "sp-agent-1", tid: IDP_TID, scope: ["emails:read"] }); + const response = await handleSelfHostedRequest(deps, sendRequest(token)); + expect(response.status).toBe(403); + const body = (await response.json()) as { reason?: string }; + expect(body.reason).toBe("insufficient_scope"); + expect(policyInputs).toHaveLength(0); + }); + + it("still refuses an unmapped IdP principal before any send machinery runs", async () => { + const { deps, policyInputs } = harness(new Map()); + const { token } = signTestIdpToken(key, { + sub: "sp-unmapped", + tid: IDP_TID, + scope: ["emails:read", "emails:write"], + }); + const response = await handleSelfHostedRequest(deps, sendRequest(token)); + expect(response.status).toBe(403); + const body = (await response.json()) as { reason?: string }; + expect(body.reason).toBe("no_tenant"); + expect(policyInputs).toHaveLength(0); + }); +}); diff --git a/src/server/self-hosted/idp.integration.test.ts b/src/server/self-hosted/idp.integration.test.ts index 933ee05d..9fe55f34 100644 --- a/src/server/self-hosted/idp.integration.test.ts +++ b/src/server/self-hosted/idp.integration.test.ts @@ -106,6 +106,23 @@ describe.skipIf(!pg)("migration 0021 — idp_principal_tenants", () => { await pg!.execute(migration.sql); expect(migration).toBeDefined(); }); + + it("keys the table on (sub, tenant_id) after 0022, idempotently", async () => { + const migration = emailsSelfHostedMigrations().find( + (m) => m.id === "0022_idp_principal_tenants_multi_grant", + )!; + await pg!.execute(migration.sql); + await pg!.execute(migration.sql); + const index = await pg!.get<{ indexdef: string }>( + `SELECT indexdef FROM pg_indexes + WHERE schemaname = 'public' AND indexname = 'idp_principal_tenants_sub_tenant_key'`, + ); + expect(index?.indexdef).toContain("UNIQUE"); + const pk = await pg!.get<{ conname: string }>( + `SELECT conname FROM pg_constraint WHERE conname = 'idp_principal_tenants_pkey'`, + ); + expect(pk).toBeNull(); + }); }); describe.skipIf(!pg)("AuthStore idp mapping — real SQL round-trip", () => { @@ -118,7 +135,7 @@ describe.skipIf(!pg)("AuthStore idp mapping — real SQL round-trip", () => { idpTid: IDP_TID, note: "integration", }); - const mapping = await store().getIdpPrincipalTenant("sp-roundtrip"); + const [mapping] = await store().listIdpPrincipalTenantsForSub("sp-roundtrip"); expect(mapping).toMatchObject({ sub: "sp-roundtrip", tenantId: TENANT_ID, @@ -128,13 +145,42 @@ describe.skipIf(!pg)("AuthStore idp mapping — real SQL round-trip", () => { }); expect(await store().revokeIdpPrincipalTenant("sp-roundtrip")).toBe(true); - const revoked = await store().getIdpPrincipalTenant("sp-roundtrip"); + const [revoked] = await store().listIdpPrincipalTenantsForSub("sp-roundtrip"); expect(revoked?.revokedAt).not.toBeNull(); // Second revoke is a no-op, reported as such. expect(await store().revokeIdpPrincipalTenant("sp-roundtrip")).toBe(false); - // Re-granting clears the revocation (explicit re-grant, audited by caller). - await store().upsertIdpPrincipalTenant({ sub: "sp-roundtrip", tenantId: TENANT_ID, idpTid: IDP_TID }); - expect((await store().getIdpPrincipalTenant("sp-roundtrip"))?.revokedAt).toBeNull(); + // Re-granting must NOT resurrect the kill switch: the revocation stands + // until the explicit restore operation lifts it. + const regrant = await store().upsertIdpPrincipalTenant({ sub: "sp-roundtrip", tenantId: TENANT_ID, idpTid: IDP_TID }); + expect(regrant?.revokedAt).not.toBeNull(); + expect((await store().listIdpPrincipalTenantsForSub("sp-roundtrip"))[0]?.revokedAt).not.toBeNull(); + expect(await store().restoreIdpPrincipalTenant("sp-roundtrip", TENANT_ID)).toBe(true); + expect((await store().listIdpPrincipalTenantsForSub("sp-roundtrip"))[0]?.revokedAt).toBeNull(); + }); + + it("holds several tenant grants per sub, each with an independent kill switch", async () => { + const second = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + await pg!.execute( + `INSERT INTO tenants (id, slug, name, status) VALUES ($1, 'idp-second', 'Second', 'active') + ON CONFLICT (id) DO NOTHING`, + [second], + ); + await store().upsertIdpPrincipalTenant({ sub: "sp-multi", tenantId: TENANT_ID, idpTid: IDP_TID }); + await store().upsertIdpPrincipalTenant({ sub: "sp-multi", tenantId: second, idpTid: IDP_TID }); + const grants = await store().listIdpPrincipalTenantsForSub("sp-multi"); + // Granting the second tenant did NOT re-point (revoke) the first grant. + expect(grants.map((g) => g.tenantId).sort()).toEqual([TENANT_ID, second].sort()); + + // A tenant-scoped revoke kills exactly one grant. + expect(await store().revokeIdpPrincipalTenant("sp-multi", second)).toBe(true); + const after = await store().listIdpPrincipalTenantsForSub("sp-multi"); + expect(after.find((g) => g.tenantId === second)?.revokedAt).not.toBeNull(); + expect(after.find((g) => g.tenantId === TENANT_ID)?.revokedAt).toBeNull(); + + // The unscoped incident path kills everything left. + expect(await store().revokeIdpPrincipalTenant("sp-multi")).toBe(true); + const killed = await store().listIdpPrincipalTenantsForSub("sp-multi"); + expect(killed.every((g) => g.revokedAt !== null)).toBe(true); }); it("fails closed when the mapped tenant is suspended", async () => { @@ -145,7 +191,7 @@ describe.skipIf(!pg)("AuthStore idp mapping — real SQL round-trip", () => { [suspended], ); await store().upsertIdpPrincipalTenant({ sub: "sp-suspended", tenantId: suspended }); - expect(await store().getIdpPrincipalTenant("sp-suspended")).toBeNull(); + expect(await store().listIdpPrincipalTenantsForSub("sp-suspended")).toEqual([]); }); }); diff --git a/src/server/self-hosted/inbound.test.ts b/src/server/self-hosted/inbound.test.ts index 7150424e..5c7dbcb6 100644 --- a/src/server/self-hosted/inbound.test.ts +++ b/src/server/self-hosted/inbound.test.ts @@ -211,7 +211,8 @@ describe("Emails self-hosted inbound messages", () => { expect(ids).toContain("0018_send_intent_recovery"); expect(ids).toContain("0019_inbox_perf_rollups"); expect(ids).toContain("0020_attachment_repair_ledger"); - expect(ids.at(-1)).toBe("0021_idp_principal_tenants"); + expect(ids).toContain("0021_idp_principal_tenants"); + expect(ids.at(-1)).toBe("0022_idp_principal_tenants_multi_grant"); }); test("POST inbound preserves all fields and returns 201", async () => { diff --git a/src/server/self-hosted/migrations.ts b/src/server/self-hosted/migrations.ts index 5458df41..f6ff4625 100644 --- a/src/server/self-hosted/migrations.ts +++ b/src/server/self-hosted/migrations.ts @@ -2707,6 +2707,28 @@ const IDP_PRINCIPAL_TENANTS = defineMigration( `, ); +/** + * 0022 — key idp_principal_tenants on (sub, tenant_id) (additive only). + * + * The 0021 shape (`sub text PRIMARY KEY`) meant one principal could hold + * exactly one tenant grant, so granting tenant B silently re-pointed — i.e. + * revoked — an existing tenant-A grant, with no history and no signal. The + * composite key lets one principal hold several tenant grants, each with its + * own independent revoked_at kill switch. No rows are dropped or rewritten; + * the unique composite index is created BEFORE the sub-only primary key is + * dropped so uniqueness never lapses mid-migration, and a plain index on sub + * keeps the resolution lookup indexed. + */ +const IDP_PRINCIPAL_TENANTS_MULTI_GRANT = defineMigration( + "0022_idp_principal_tenants_multi_grant", + ` + CREATE UNIQUE INDEX IF NOT EXISTS idp_principal_tenants_sub_tenant_key + ON idp_principal_tenants (sub, tenant_id); + ALTER TABLE idp_principal_tenants DROP CONSTRAINT IF EXISTS idp_principal_tenants_pkey; + CREATE INDEX IF NOT EXISTS idp_principal_tenants_sub_idx ON idp_principal_tenants (sub); + `, +); + /** All migrations, in order: api-keys table (auth), the core schema, inbound. */ export function emailsSelfHostedMigrations(): Migration[] { const authMigrations = apiKeyMigrations().map((m) => defineMigration(m.id, m.sql)); @@ -2735,5 +2757,6 @@ export function emailsSelfHostedMigrations(): Migration[] { INBOX_PERF_ROLLUPS, ATTACHMENT_REPAIR_LEDGER, IDP_PRINCIPAL_TENANTS, + IDP_PRINCIPAL_TENANTS_MULTI_GRANT, ]; } diff --git a/src/server/self-hosted/openapi.ts b/src/server/self-hosted/openapi.ts index 5ad9f61d..865d619a 100644 --- a/src/server/self-hosted/openapi.ts +++ b/src/server/self-hosted/openapi.ts @@ -460,6 +460,21 @@ const tenantKeyListItemSchema = { ], } as const; +/** One IdP-principal federation grant row (ADR-0001/0002 operator surface). */ +const idpPrincipalGrantSchema = { + type: "object", + properties: { + sub: { type: "string" }, + tenant_id: { type: "string" }, + idp_tid: { type: "string", nullable: true }, + principal_type: { type: "string", enum: ["user", "service"] }, + note: { type: "string", nullable: true }, + created_at: { type: "string", format: "date-time" }, + revoked_at: { type: "string", format: "date-time", nullable: true }, + }, + required: ["sub", "tenant_id", "idp_tid", "principal_type", "revoked_at"], +} as const; + const sendKeySchema = { type: "object", properties: { @@ -1517,6 +1532,7 @@ const listParams = [ ] as const; const idParam = [{ name: "id", in: "path", required: true, schema: { type: "string" } }] as const; +const subParam = [{ name: "sub", in: "path", required: true, schema: { type: "string" } }] as const; const attachmentRepairIdParam = [{ name: "id", in: "path", @@ -1770,7 +1786,9 @@ function addRoutineErrorParity(document: EmailsOpenApiDocument): void { || path.startsWith("/v1/memberships/") || path === "/v1/invites/accept" || path === "/v1/keys" - || path.startsWith("/v1/keys/"); + || path.startsWith("/v1/keys/") + || path === "/v1/idp-principals" + || path.startsWith("/v1/idp-principals/"); if (protectedVersionedOperation) { setRoutineErrorResponse( @@ -2934,6 +2952,135 @@ export const emailsSelfHostedOpenApi: EmailsOpenApiDocument = { }, }, }, + "/v1/idp-principals": { + get: { + operationId: "listIdpPrincipals", + summary: "List this tenant's IdP-principal federation grants (revoked included); tenant operator required", + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { + idp_principals: { + type: "array", + items: idpPrincipalGrantSchema, + }, + }, + required: ["idp_principals"], + }, + }, + }, + }, + }, + }, + post: { + operationId: "grantIdpPrincipal", + summary: "Grant an IdP principal (sub) access to the caller's tenant; a re-grant never un-revokes", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + sub: { type: "string" }, + idp_tid: { type: "string", nullable: true }, + principal_type: { type: "string", enum: ["user", "service"] }, + note: { type: "string", nullable: true }, + }, + required: ["sub"], + }, + }, + }, + }, + responses: { + "201": { + content: { + "application/json": { + schema: { + type: "object", + properties: { + grant: idpPrincipalGrantSchema, + warning: { type: "string" }, + }, + required: ["grant"], + }, + }, + }, + }, + }, + }, + }, + "/v1/idp-principals/{sub}": { + delete: { + operationId: "revokeIdpPrincipal", + summary: "Throw the emails-side kill switch on a federation grant; tenant operator required", + parameters: [...subParam], + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { + revoked: trueSchema, + sub: { type: "string" }, + }, + required: ["revoked", "sub"], + }, + }, + }, + }, + }, + }, + }, + "/v1/idp-principals/{sub}/revoke": { + post: { + operationId: "revokeIdpPrincipalByPost", + summary: "Compatibility verb for revoking a federation grant", + parameters: [...subParam], + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { + revoked: trueSchema, + sub: { type: "string" }, + }, + required: ["revoked", "sub"], + }, + }, + }, + }, + }, + }, + }, + "/v1/idp-principals/{sub}/restore": { + post: { + operationId: "restoreIdpPrincipal", + summary: "Deliberately lift the kill switch on one federation grant; tenant operator required", + parameters: [...subParam], + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { + restored: trueSchema, + sub: { type: "string" }, + }, + required: ["restored", "sub"], + }, + }, + }, + }, + }, + }, + }, "/v1/domains": { get: { operationId: "listDomains", diff --git a/src/server/self-hosted/serve.ts b/src/server/self-hosted/serve.ts index b827f771..a5ad29b8 100644 --- a/src/server/self-hosted/serve.ts +++ b/src/server/self-hosted/serve.ts @@ -6,7 +6,7 @@ import { ApiKeyStore, type ApiKeyVerifier } from "@hasna/contracts/auth"; import { assertServingRoleCannotBypassRls } from "./rls-guard.js"; import { getSelfHostedPool, requireSigningSecret, SELF_HOSTED_APP, SELF_HOSTED_APP_ALIASES } from "./env.js"; -import { verifyApiKeyWithAliases } from "./api-key-verifier.js"; +import { formatApiAuthAuditLine, verifyApiKeyWithAliases } from "./api-key-verifier.js"; import { emailsSelfHostedMigrations } from "./migrations.js"; import { EmailsSelfHostedStore } from "./store.js"; import { handleSelfHostedRequest, type SelfHostedServiceDeps } from "./service.js"; @@ -38,11 +38,8 @@ export function buildSelfHostedService(version: string): SelfHostedServiceDeps { signingSecret, isRevoked: keys.statusChecker(), audit: (e) => { - // Structured, secret-free audit line (kid + outcome only). - console.log( - `[api-auth] ${e.outcome} app=${e.app} kid=${e.kid ?? "-"} reason=${e.reason ?? "-"} ` + - `${e.method ?? "-"} ${e.path ?? "-"} status=${e.status}`, - ); + // Structured, secret-free audit line (kid + tenant + outcome only). + console.log(formatApiAuthAuditLine(e)); }, }, [SELF_HOSTED_APP, ...SELF_HOSTED_APP_ALIASES], diff --git a/src/server/self-hosted/service.ts b/src/server/self-hosted/service.ts index 9fbb39ce..184ccb06 100644 --- a/src/server/self-hosted/service.ts +++ b/src/server/self-hosted/service.ts @@ -7,7 +7,7 @@ // All data operations hit the operator-owned Postgres via the // store, which wraps the product-owned storage utilities' typed client. -import type { ApiKeyVerifier } from "@hasna/contracts/auth"; +import { hasAllScopes, type ApiKeyVerifier } from "@hasna/contracts/auth"; import { createHash } from "node:crypto"; import { migrationAcceptsChecksum, type TypedQueryClient, type Migration } from "../../storage-kit/index.js"; import { checkHealth } from "../../storage-kit/index.js"; @@ -1122,10 +1122,14 @@ export async function handleSelfHostedRequest( providerMessageId: providerMessageId || null, evidence, // Who asserted the outcome — an opaque principal reference, never a - // credential. `kid` is the API key id, not the key material. + // credential. `kid` is the API key id, not the key material; `sub` + // is the IdP principal id. Naming the actual class matters: the + // fallthrough used to file IdP principals as 'apikey:unknown'. resolvedBy: auth.ctx.principalType === "user" ? `user:${auth.ctx.userId ?? "unknown"}` - : `apikey:${auth.ctx.kid ?? "unknown"}`, + : auth.ctx.principalType === "idp" + ? `idp:${auth.ctx.sub ?? "unknown"}` + : `apikey:${auth.ctx.kid ?? "unknown"}`, }); } catch (error) { return json(400, { error: error instanceof Error ? error.message : "reconciliation rejected" }); @@ -1341,8 +1345,16 @@ export async function handleSelfHostedRequest( from, recipients: [...to, ...cc, ...bcc], sendKeyToken: sendKeyToken || null, + // Tenant-wide send authority: API keys carry it structurally, tenant + // owner/admin sessions carry it by role, and an IdP-federated principal + // carries it when its (already tenant-scoped) grant includes the + // ordinary write scope — IdP principals have no role and no path to a + // send key (minting is operator-gated), so omitting the class here + // made every federated send an unfixable 403 send_key_required. allowTenantWideSend: - auth.ctx.principalType === "apikey" || auth.ctx.role === "owner" || auth.ctx.role === "admin", + auth.ctx.principalType === "apikey" || + (auth.ctx.principalType === "idp" && hasAllScopes(auth.ctx.scopes, ["emails:write"])) || + auth.ctx.role === "owner" || auth.ctx.role === "admin", }); if (!policy.allowed) { const blocked = await auth.store.markSendBlocked(reserved.record.id, policy.code).catch(() => null);