From c88065213b21cc14427e512c024810603d7366bb Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 13:35:53 -0700 Subject: [PATCH 1/8] feat(hub): add identity transaction kernel Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/architecture.md | 6 +- hub/docs/backend-architecture.md | 6 + hub/docs/database.md | 6 +- hub/docs/testing-and-operations.md | 2 +- hub/schema/authentication-provider.prisma | 41 ++++ hub/schema/external-identity.prisma | 21 ++ .../migration.sql | 180 ++++++++++++++ hub/schema/migrations/migration_lock.toml | 1 + hub/schema/security-audit-outcome.enum.prisma | 5 + hub/schema/security-audit.prisma | 18 ++ hub/schema/user.prisma | 13 ++ .../index.test.ts | 39 ++++ .../index.ts | 79 +++++++ .../types.ts | 11 + hub/server/domains/identity/errors.ts | 51 ++++ .../get-selected-provider-revision/index.ts | 27 +++ .../get-selected-provider-revision/types.ts | 5 + .../index.db.test.ts | 219 ++++++++++++++++++ .../index.ts | 117 ++++++++++ .../types.ts | 15 ++ .../resolve-external-identity/index.ts | 22 ++ .../resolve-external-identity/types.ts | 8 + .../index.db.test.ts | 199 ++++++++++++++++ .../index.ts | 65 ++++++ .../types.ts | 17 ++ .../index.ts | 33 +++ .../types.ts | 18 ++ .../index.ts | 37 +++ .../types.ts | 37 +++ .../index.ts | 11 + .../types.ts | 14 ++ .../create-authentication-provider/index.ts | 11 + .../create-authentication-provider/types.ts | 11 + .../find-authentication-provider/index.ts | 11 + .../find-authentication-provider/types.ts | 6 + .../index.ts | 14 ++ .../types.ts | 11 + .../create-external-identity/index.ts | 8 + .../create-external-identity/types.ts | 11 + .../find-external-identity/index.ts | 10 + .../find-external-identity/types.ts | 9 + .../create-security-audit/index.test.ts | 53 +++++ .../create-security-audit/index.ts | 49 ++++ .../create-security-audit/types.ts | 20 ++ .../repositories/users/create-user/index.ts | 5 + .../repositories/users/create-user/types.ts | 7 + hub/test/database/global-setup/index.ts | 4 +- .../identity-foundation/index.db.test.ts | 129 +++++++++++ 48 files changed, 1685 insertions(+), 7 deletions(-) create mode 100644 hub/schema/authentication-provider.prisma create mode 100644 hub/schema/external-identity.prisma create mode 100644 hub/schema/migrations/20261029203000_identity_foundation/migration.sql create mode 100644 hub/schema/migrations/migration_lock.toml create mode 100644 hub/schema/security-audit-outcome.enum.prisma create mode 100644 hub/schema/security-audit.prisma create mode 100644 hub/schema/user.prisma create mode 100644 hub/server/domains/identity/create-validated-provider-revision/index.test.ts create mode 100644 hub/server/domains/identity/create-validated-provider-revision/index.ts create mode 100644 hub/server/domains/identity/create-validated-provider-revision/types.ts create mode 100644 hub/server/domains/identity/errors.ts create mode 100644 hub/server/domains/identity/get-selected-provider-revision/index.ts create mode 100644 hub/server/domains/identity/get-selected-provider-revision/types.ts create mode 100644 hub/server/domains/identity/persist-validated-provider-revision/index.db.test.ts create mode 100644 hub/server/domains/identity/persist-validated-provider-revision/index.ts create mode 100644 hub/server/domains/identity/persist-validated-provider-revision/types.ts create mode 100644 hub/server/domains/identity/resolve-external-identity/index.ts create mode 100644 hub/server/domains/identity/resolve-external-identity/types.ts create mode 100644 hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts create mode 100644 hub/server/domains/identity/resolve-or-create-external-identity/index.ts create mode 100644 hub/server/domains/identity/resolve-or-create-external-identity/types.ts create mode 100644 hub/server/domains/identity/run-identity-creation-transaction/index.ts create mode 100644 hub/server/domains/identity/run-identity-creation-transaction/types.ts create mode 100644 hub/server/domains/identity/run-provider-revision-transaction/index.ts create mode 100644 hub/server/domains/identity/run-provider-revision-transaction/types.ts create mode 100644 hub/server/repositories/authentication-providers/create-authentication-provider-revision/index.ts create mode 100644 hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts create mode 100644 hub/server/repositories/authentication-providers/create-authentication-provider/index.ts create mode 100644 hub/server/repositories/authentication-providers/create-authentication-provider/types.ts create mode 100644 hub/server/repositories/authentication-providers/find-authentication-provider/index.ts create mode 100644 hub/server/repositories/authentication-providers/find-authentication-provider/types.ts create mode 100644 hub/server/repositories/authentication-providers/select-authentication-provider-revision/index.ts create mode 100644 hub/server/repositories/authentication-providers/select-authentication-provider-revision/types.ts create mode 100644 hub/server/repositories/external-identities/create-external-identity/index.ts create mode 100644 hub/server/repositories/external-identities/create-external-identity/types.ts create mode 100644 hub/server/repositories/external-identities/find-external-identity/index.ts create mode 100644 hub/server/repositories/external-identities/find-external-identity/types.ts create mode 100644 hub/server/repositories/security-audits/create-security-audit/index.test.ts create mode 100644 hub/server/repositories/security-audits/create-security-audit/index.ts create mode 100644 hub/server/repositories/security-audits/create-security-audit/types.ts create mode 100644 hub/server/repositories/users/create-user/index.ts create mode 100644 hub/server/repositories/users/create-user/types.ts create mode 100644 hub/test/database/identity-foundation/index.db.test.ts diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 65dad84c..58ec3c22 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -8,7 +8,7 @@ The team has chosen TypeScript, Express for the API, React and Vite for the fron The API responds to a health check, and the frontend displays the logo with light and dark themes. Storybook keeps the existing components and pages available for reuse. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. Sign-in, deployment, and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has a provider-neutral identity transaction foundation, but it does not issue sessions or admit users to Hub. Sign-in, deployment, and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. ## Backend @@ -26,6 +26,10 @@ The [telemetry integration](telemetry.md) provides explicit server-side OpenTele The [WebSocket foundation](websockets.md) attaches to the API's HTTP server and broadcasts to local channel subscribers without persistence. The application rejects upgrades until access checks are supplied. Product channels, client recovery, and communication between processes are not implemented. +The identity foundation stores Hub users, immutable external identity mappings, selected authentication provider revisions, and bounded security audits. Domain operations receive only the transaction-scoped repositories they need. A successful state change and its success audit commit together; a rejected transaction rolls back before its denial or failure audit is written. Provider and other external I/O must finish before a provider revision enters the database transaction. + +This foundation does not prove a provider assertion, create a usable Hub session, assign membership, or authorize product access. + ## Frontend ```mermaid diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 60976740..3bb0f0a0 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -40,3 +40,9 @@ Add directories when their functions are needed. Do not add empty implementation ## Database access The [shared Prisma client](database.md#shared-client) lives in `server/integrations/prisma/index.ts`. Domains may pass it to repositories for database queries without letting repositories import integrations. Keep model definitions, naming, and migrations consistent with the [database conventions](database.md#schema-conventions). + +## Identity transactions + +The identity kernel keeps database access in focused repositories for users, external identities, authentication provider revisions, and security audits. Its domain operations accept transaction-scoped repository functions rather than a mutable store or a global lock. + +Keep network, provider, and filesystem work outside the transaction. A transaction may change one identity or authentication-provider aggregate and write its success audit. When work is denied or fails, roll back that transaction before writing the separate denial or failure audit. diff --git a/hub/docs/database.md b/hub/docs/database.md index 1663e7f1..4f5a332b 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. Product models and migrations have not been added. +Hub uses PostgreSQL and Prisma. The schema contains the provider-neutral identity transaction foundation. Hub sessions, memberships, invitations, source integrations, and product records are not part of this foundation. [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. @@ -66,6 +66,6 @@ npm run db:generate `npm ci` also generates the client. Generated TypeScript lives under the ignored `server/generated/prisma/` directory and compiles with the API. Schema validation and generation do not require a running database or `DATABASE_URL`; migration and database commands require both. Generate again after changing a schema. -Use `db:migrate` only with a local development database. It needs permission to create a shadow database to calculate migrations. `npm run db:deploy` applies committed migrations without creating new ones; it is not a production deployment setup. `npm run db:studio` opens Prisma's database editor and should be used only with an intended development database. +Use `db:migrate` only with a local development database. It needs permission to create a shadow database to calculate migrations. `npm run db:deploy` applies committed migrations without creating new ones; database tests use this command so constraints in committed migrations are exercised. It is not a production deployment setup. `npm run db:studio` opens Prisma's database editor and should be used only with an intended development database. -The [database-enabled test projects](testing-and-operations.md#parallel-database-tests) use separate databases for every project, worker, and test run. Setup applies the current Prisma schema directly and clears records before each case. Tests do not use the development database for records. +The [database-enabled test projects](testing-and-operations.md#parallel-database-tests) use separate databases for every project, worker, and test run. Setup deploys the committed migrations and clears records before each case. Tests do not use the development database for records. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 6d23554f..4d97a455 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ Database-enabled jsdom tests use the browser setup and database hooks together. The database setup follows this lifecycle: -1. Before workers start, setup creates a template database with a unique name for each project and run. It applies the current Prisma schema with `prisma db push` and BullMQ's migrations, closes those setup connections, then copies the template for every worker slot. +1. Before workers start, setup creates a template database with a unique name for each project and run. It deploys the committed Prisma migrations and BullMQ's migrations, closes those setup connections, then copies the template for every worker slot. 2. Each worker receives its own `DATABASE_URL` before application modules load. Setup uses Vitest's [`VITEST_POOL_ID`](https://vitest.dev/guide/migration/jest#envs), which stays within the configured worker limit. 3. Before every case, setup empties application and job tables across user schemas and resets generated identity counters. It preserves `public._prisma_migrations` and `bullmq.migration`. Each case creates its own records; there is no shared seed data. 4. After the run, teardown drops only the databases it created, even if tests left connections open. diff --git a/hub/schema/authentication-provider.prisma b/hub/schema/authentication-provider.prisma new file mode 100644 index 00000000..e523a4f2 --- /dev/null +++ b/hub/schema/authentication-provider.prisma @@ -0,0 +1,41 @@ +model AuthenticationProvider { + // ID columns + id String @id @default(uuid()) @db.Uuid + selectedRevisionId String? @unique @map("selected_revision_id") @db.Uuid + + // Relations + revisions AuthenticationProviderRevision[] @relation("AuthenticationProviderRevisions") + selectedRevision AuthenticationProviderRevision? @relation("SelectedAuthenticationProviderRevision", fields: [selectedRevisionId], references: [id], onDelete: Restrict) + + // Attribute columns + key String @unique @db.VarChar(100) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("authentication_providers") +} + +model AuthenticationProviderRevision { + // ID columns + id String @id @default(uuid()) @db.Uuid + providerId String @map("provider_id") @db.Uuid + + // Relations + provider AuthenticationProvider @relation("AuthenticationProviderRevisions", fields: [providerId], references: [id], onDelete: Restrict) + selectedBy AuthenticationProvider? @relation("SelectedAuthenticationProviderRevision") + + // Attribute columns + revision Int + configurationDigest String @map("configuration_digest") @db.VarChar(64) + settings Json @db.JsonB + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([providerId, revision]) + @@index([providerId]) + @@map("authentication_provider_revisions") +} diff --git a/hub/schema/external-identity.prisma b/hub/schema/external-identity.prisma new file mode 100644 index 00000000..6b5fa3a7 --- /dev/null +++ b/hub/schema/external-identity.prisma @@ -0,0 +1,21 @@ +model ExternalIdentity { + // ID columns + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + // Attribute columns + provider String @db.VarChar(100) + issuer String @db.VarChar(500) + subject String @db.VarChar(500) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([provider, issuer, subject]) + @@index([userId]) + @@map("external_identities") +} diff --git a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql new file mode 100644 index 00000000..5e064821 --- /dev/null +++ b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql @@ -0,0 +1,180 @@ +CREATE TYPE "SecurityAuditOutcome" AS ENUM ('SUCCESS', 'DENIED', 'FAILURE'); + +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "external_identities" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "provider" VARCHAR(100) NOT NULL, + "issuer" VARCHAR(500) NOT NULL, + "subject" VARCHAR(500) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "external_identities_pkey" PRIMARY KEY ("id"), + CONSTRAINT "external_identities_values_present" CHECK ( + length("provider") > 0 AND length("issuer") > 0 AND length("subject") > 0 + ) +); + +CREATE TABLE "authentication_providers" ( + "id" UUID NOT NULL, + "selected_revision_id" UUID, + "key" VARCHAR(100) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_providers_pkey" PRIMARY KEY ("id"), + CONSTRAINT "authentication_providers_key_present" CHECK (length("key") > 0) +); + +CREATE TABLE "authentication_provider_revisions" ( + "id" UUID NOT NULL, + "provider_id" UUID NOT NULL, + "revision" INTEGER NOT NULL, + "configuration_digest" VARCHAR(64) NOT NULL, + "settings" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_provider_revisions_pkey" PRIMARY KEY ("id"), + CONSTRAINT "authentication_provider_revisions_revision_positive" CHECK ("revision" > 0), + CONSTRAINT "authentication_provider_revisions_digest_sha256" CHECK ( + "configuration_digest" ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT "authentication_provider_revisions_settings_object" CHECK ( + jsonb_typeof("settings") = 'object' + ) +); + +CREATE TABLE "security_audits" ( + "id" UUID NOT NULL, + "action" VARCHAR(100) NOT NULL, + "outcome" "SecurityAuditOutcome" NOT NULL, + "target_type" VARCHAR(100) NOT NULL, + "target_id" VARCHAR(100), + "context" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "security_audits_pkey" PRIMARY KEY ("id"), + CONSTRAINT "security_audits_values_present" CHECK ( + length("action") > 0 AND length("target_type") > 0 + ), + CONSTRAINT "security_audits_context_object" CHECK (jsonb_typeof("context") = 'object'), + CONSTRAINT "security_audits_context_bounded" CHECK (pg_column_size("context") <= 4096) +); + +CREATE UNIQUE INDEX "external_identities_provider_issuer_subject_key" +ON "external_identities"("provider", "issuer", "subject"); + +CREATE INDEX "external_identities_user_id_idx" ON "external_identities"("user_id"); + +CREATE UNIQUE INDEX "authentication_providers_selected_revision_id_key" +ON "authentication_providers"("selected_revision_id"); + +CREATE UNIQUE INDEX "authentication_providers_key_key" ON "authentication_providers"("key"); + +CREATE UNIQUE INDEX "authentication_provider_revisions_provider_id_revision_key" +ON "authentication_provider_revisions"("provider_id", "revision"); + +CREATE INDEX "authentication_provider_revisions_provider_id_idx" +ON "authentication_provider_revisions"("provider_id"); + +CREATE INDEX "security_audits_action_created_at_idx" +ON "security_audits"("action", "created_at"); + +ALTER TABLE "external_identities" +ADD CONSTRAINT "external_identities_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "authentication_provider_revisions" +ADD CONSTRAINT "authentication_provider_revisions_provider_id_fkey" +FOREIGN KEY ("provider_id") REFERENCES "authentication_providers"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "authentication_providers" +ADD CONSTRAINT "authentication_providers_selected_revision_id_fkey" +FOREIGN KEY ("selected_revision_id") REFERENCES "authentication_provider_revisions"("id") +ON DELETE RESTRICT ON UPDATE CASCADE +DEFERRABLE INITIALLY DEFERRED; + +CREATE FUNCTION prevent_external_identity_reassignment() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW."user_id" <> OLD."user_id" + OR NEW."provider" <> OLD."provider" + OR NEW."issuer" <> OLD."issuer" + OR NEW."subject" <> OLD."subject" THEN + RAISE EXCEPTION 'External identity mappings are immutable.'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "external_identities_immutable_mapping" +BEFORE UPDATE ON "external_identities" +FOR EACH ROW EXECUTE FUNCTION prevent_external_identity_reassignment(); + +CREATE FUNCTION prevent_authentication_provider_revision_changes() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' + OR NEW."provider_id" <> OLD."provider_id" + OR NEW."revision" <> OLD."revision" + OR NEW."configuration_digest" <> OLD."configuration_digest" + OR NEW."settings" <> OLD."settings" THEN + RAISE EXCEPTION 'Authentication provider revisions are immutable.'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "authentication_provider_revisions_immutable" +BEFORE UPDATE OR DELETE ON "authentication_provider_revisions" +FOR EACH ROW EXECUTE FUNCTION prevent_authentication_provider_revision_changes(); + +CREATE FUNCTION prevent_security_audit_changes() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'Security audit records are immutable.'; +END; +$$; + +CREATE TRIGGER "security_audits_immutable" +BEFORE UPDATE OR DELETE ON "security_audits" +FOR EACH ROW EXECUTE FUNCTION prevent_security_audit_changes(); + +CREATE FUNCTION enforce_selected_revision_provider() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW."selected_revision_id" IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM "authentication_provider_revisions" + WHERE "id" = NEW."selected_revision_id" + AND "provider_id" = NEW."id" + ) THEN + RAISE EXCEPTION 'Selected revision must belong to its authentication provider.'; + END IF; + RETURN NEW; +END; +$$; + +CREATE CONSTRAINT TRIGGER "authentication_provider_selected_revision_owner" +AFTER INSERT OR UPDATE OF "selected_revision_id" ON "authentication_providers" +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW EXECUTE FUNCTION enforce_selected_revision_provider(); diff --git a/hub/schema/migrations/migration_lock.toml b/hub/schema/migrations/migration_lock.toml new file mode 100644 index 00000000..2fe25d87 --- /dev/null +++ b/hub/schema/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/hub/schema/security-audit-outcome.enum.prisma b/hub/schema/security-audit-outcome.enum.prisma new file mode 100644 index 00000000..7bfa7f4b --- /dev/null +++ b/hub/schema/security-audit-outcome.enum.prisma @@ -0,0 +1,5 @@ +enum SecurityAuditOutcome { + SUCCESS + DENIED + FAILURE +} diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma new file mode 100644 index 00000000..02785234 --- /dev/null +++ b/hub/schema/security-audit.prisma @@ -0,0 +1,18 @@ +model SecurityAudit { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Attribute columns + action String @db.VarChar(100) + outcome SecurityAuditOutcome + targetType String @map("target_type") @db.VarChar(100) + targetId String? @map("target_id") @db.VarChar(100) + context Json @db.JsonB + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([action, createdAt]) + @@map("security_audits") +} diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma new file mode 100644 index 00000000..fa4921db --- /dev/null +++ b/hub/schema/user.prisma @@ -0,0 +1,13 @@ +model User { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + externalIdentities ExternalIdentity[] + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("users") +} diff --git a/hub/server/domains/identity/create-validated-provider-revision/index.test.ts b/hub/server/domains/identity/create-validated-provider-revision/index.test.ts new file mode 100644 index 00000000..72e3fcb2 --- /dev/null +++ b/hub/server/domains/identity/create-validated-provider-revision/index.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" + +import { createValidatedProviderRevision } from "." + +describe("createValidatedProviderRevision()", () => { + it("creates the same digest for equivalent settings regardless of key order", () => { + const first = createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { issuer: "https://id.example.test", audience: "hub" }, + }) + const second = createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { audience: "hub", issuer: "https://id.example.test" }, + }) + + expect(first.configurationDigest).toMatch(/^[0-9a-f]{64}$/) + expect(second.configurationDigest).toBe(first.configurationDigest) + expect(second.settings).toEqual(first.settings) + }) + + it("rejects credentials and settings that exceed the bounded contract", () => { + expect(() => + createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { clientSecret: "must-not-persist" }, + }), + ).toThrow("cannot contain credentials or secrets") + expect(() => + createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { issuer: "x".repeat(2_001) }, + }), + ).toThrow("cannot exceed 2000 characters") + }) +}) diff --git a/hub/server/domains/identity/create-validated-provider-revision/index.ts b/hub/server/domains/identity/create-validated-provider-revision/index.ts new file mode 100644 index 00000000..71157c7c --- /dev/null +++ b/hub/server/domains/identity/create-validated-provider-revision/index.ts @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto" +import { z } from "zod" + +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { CreateValidatedProviderRevisionInput, ValidatedProviderRevision } from "./types.ts" + +const sensitiveKey = /(?:authorization|cookie|credential|password|private.?key|secret|token)/i +const providerKeySchema = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/) + +function isJsonArray(value: Prisma.InputJsonValue): value is Prisma.InputJsonArray { + return Array.isArray(value) +} + +function normalizeSettingsObject(value: Prisma.InputJsonObject, depth = 0): Prisma.InputJsonObject { + if (depth > 4) throw new Error("Provider settings cannot exceed four nested levels.") + const entries = Object.entries(value) + if (entries.length > 32) throw new Error("Provider settings cannot exceed 32 fields per object.") + return Object.fromEntries( + entries + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => { + if (!key.length || key.length > 100) { + throw new Error("Provider setting keys must contain between 1 and 100 characters.") + } + if (sensitiveKey.test(key)) { + throw new Error("Provider settings cannot contain credentials or secrets.") + } + if (item === undefined) { + throw new Error("Provider settings cannot contain undefined values.") + } + return [key, normalizeSettingValue(item, depth + 1)] + }), + ) +} + +function normalizeSettingValue( + value: Prisma.InputJsonValue | null, + depth: number, +): Prisma.InputJsonValue | null { + if (depth > 4) throw new Error("Provider settings cannot exceed four nested levels.") + if (value === null || typeof value === "boolean") return value + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("Provider setting numbers must be finite.") + return value + } + if (typeof value === "string") { + if (value.length > 2_000) { + throw new Error("Provider setting strings cannot exceed 2000 characters.") + } + return value + } + if (isJsonArray(value)) { + if (value.length > 32) throw new Error("Provider setting arrays cannot exceed 32 items.") + return value.map((item) => normalizeSettingValue(item, depth + 1)) + } + if ("toJSON" in value) throw new Error("Provider settings must be plain JSON data.") + return normalizeSettingsObject(value, depth) +} + +export function createValidatedProviderRevision( + input: CreateValidatedProviderRevisionInput, +): ValidatedProviderRevision { + const providerKey = providerKeySchema.parse(input.providerKey) + if (!Number.isSafeInteger(input.revision) || input.revision < 1) { + throw new Error("Provider revision must be a positive safe integer.") + } + const settings = normalizeSettingsObject(input.settings) + const serialized = JSON.stringify(settings) + if (Buffer.byteLength(serialized, "utf8") > 16_384) { + throw new Error("Provider settings cannot exceed 16384 bytes.") + } + + return { + providerKey, + revision: input.revision, + settings, + configurationDigest: createHash("sha256").update(serialized).digest("hex"), + } +} diff --git a/hub/server/domains/identity/create-validated-provider-revision/types.ts b/hub/server/domains/identity/create-validated-provider-revision/types.ts new file mode 100644 index 00000000..6b57b6d4 --- /dev/null +++ b/hub/server/domains/identity/create-validated-provider-revision/types.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export interface CreateValidatedProviderRevisionInput { + providerKey: string + revision: number + settings: Prisma.InputJsonObject +} + +export interface ValidatedProviderRevision extends CreateValidatedProviderRevisionInput { + configurationDigest: string +} diff --git a/hub/server/domains/identity/errors.ts b/hub/server/domains/identity/errors.ts new file mode 100644 index 00000000..cc960bb5 --- /dev/null +++ b/hub/server/domains/identity/errors.ts @@ -0,0 +1,51 @@ +export class IdentityTransactionConflictError extends Error { + constructor(options?: ErrorOptions) { + super("The identity transaction conflicted with another database change.", options) + this.name = "IdentityTransactionConflictError" + } +} + +export class ExternalIdentityCollisionError extends Error { + constructor(options?: ErrorOptions) { + super( + "The external identity could not be created because its identity mapping collided.", + options, + ) + this.name = "ExternalIdentityCollisionError" + } +} + +export class ProviderRevisionConflictError extends Error { + constructor(options?: ErrorOptions) { + super("The provider revision conflicted with another provider configuration change.", options) + this.name = "ProviderRevisionConflictError" + } +} + +export class ProviderRevisionInvariantError extends Error { + constructor(message: string) { + super(message) + this.name = "ProviderRevisionInvariantError" + } +} + +export class ProviderRevisionRollbackError extends Error { + constructor() { + super("The provider revision is older than the selected revision.") + this.name = "ProviderRevisionRollbackError" + } +} + +export class ProviderRevisionDriftError extends Error { + constructor() { + super("The provider revision reuses a revision number with different settings.") + this.name = "ProviderRevisionDriftError" + } +} + +export class ProviderRevisionGapError extends Error { + constructor() { + super("The provider revision must immediately follow the selected revision.") + this.name = "ProviderRevisionGapError" + } +} diff --git a/hub/server/domains/identity/get-selected-provider-revision/index.ts b/hub/server/domains/identity/get-selected-provider-revision/index.ts new file mode 100644 index 00000000..e30d73d3 --- /dev/null +++ b/hub/server/domains/identity/get-selected-provider-revision/index.ts @@ -0,0 +1,27 @@ +import { z } from "zod" + +import { ProviderRevisionInvariantError } from "../errors.ts" +import type { GetSelectedProviderRevisionDependencies } from "./types.ts" + +const providerKeySchema = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/) + +export async function getSelectedProviderRevision( + providerKey: string, + dependencies: GetSelectedProviderRevisionDependencies, +) { + const provider = await dependencies.findAuthenticationProvider( + providerKeySchema.parse(providerKey), + ) + if (!provider) return null + if (!provider.selectedRevision) { + throw new ProviderRevisionInvariantError( + "The authentication provider does not have a selected revision.", + ) + } + if (provider.selectedRevision.providerId !== provider.id) { + throw new ProviderRevisionInvariantError( + "The selected revision does not belong to the authentication provider.", + ) + } + return provider.selectedRevision +} diff --git a/hub/server/domains/identity/get-selected-provider-revision/types.ts b/hub/server/domains/identity/get-selected-provider-revision/types.ts new file mode 100644 index 00000000..a77cc034 --- /dev/null +++ b/hub/server/domains/identity/get-selected-provider-revision/types.ts @@ -0,0 +1,5 @@ +import type { AuthenticationProviderRecord } from "../run-provider-revision-transaction/types.ts" + +export interface GetSelectedProviderRevisionDependencies { + findAuthenticationProvider(key: string): Promise +} diff --git a/hub/server/domains/identity/persist-validated-provider-revision/index.db.test.ts b/hub/server/domains/identity/persist-validated-provider-revision/index.db.test.ts new file mode 100644 index 00000000..c3898122 --- /dev/null +++ b/hub/server/domains/identity/persist-validated-provider-revision/index.db.test.ts @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { Prisma } from "../../../generated/prisma/client" +import { prisma } from "../../../integrations/prisma" +import { createAuthenticationProviderRevision } from "../../../repositories/authentication-providers/create-authentication-provider-revision" +import { createAuthenticationProvider } from "../../../repositories/authentication-providers/create-authentication-provider" +import { findAuthenticationProvider } from "../../../repositories/authentication-providers/find-authentication-provider" +import { selectAuthenticationProviderRevision } from "../../../repositories/authentication-providers/select-authentication-provider-revision" +import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit" +import { createValidatedProviderRevision } from "../create-validated-provider-revision" +import { + IdentityTransactionConflictError, + ProviderRevisionConflictError, + ProviderRevisionDriftError, + ProviderRevisionGapError, + ProviderRevisionRollbackError, +} from "../errors" +import { runProviderRevisionTransaction } from "../run-provider-revision-transaction" +import type { + ProviderRevisionRepositories, + ProviderRevisionTransaction, +} from "../run-provider-revision-transaction/types" +import { persistValidatedProviderRevision } from "." + +function dependencies() { + return { + createId: randomUUID, + runTransaction: ( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ) => runProviderRevisionTransaction(prisma, operation), + writeAudit: (input: Parameters[1]) => + createSecurityAudit(prisma, input), + } +} + +function revision(revisionNumber: number, audience = `hub-${revisionNumber}`) { + return createValidatedProviderRevision({ + providerKey: "workforce", + revision: revisionNumber, + settings: { issuer: "https://id.example.test", audience }, + }) +} + +function controlledConcurrentTransaction(): ProviderRevisionTransaction { + let arrivals = 0 + let release: (() => void) | undefined + const overlap = new Promise((resolve) => { + release = resolve + }) + + return async (operation) => { + try { + return await prisma.$transaction( + (transaction) => + operation({ + createAuthenticationProvider: (input) => + createAuthenticationProvider(transaction, input), + createAuthenticationProviderRevision: (input) => + createAuthenticationProviderRevision(transaction, input), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + findAuthenticationProvider: async (key) => { + const provider = await findAuthenticationProvider(transaction, key) + arrivals += 1 + if (arrivals === 2) release?.() + await overlap + return provider + }, + selectAuthenticationProviderRevision: (input) => + selectAuthenticationProviderRevision(transaction, input), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + ["P2002", "P2003", "P2034"].includes(error.code) + ) { + throw new IdentityTransactionConflictError({ cause: error }) + } + throw error + } + } +} + +describe("persistValidatedProviderRevision()", () => { + it("selects validated revisions atomically with a success audit", async () => { + const first = await persistValidatedProviderRevision(revision(1), dependencies()) + const second = await persistValidatedProviderRevision(revision(2), dependencies()) + + expect(first).toMatchObject({ status: "selected", revision: 1 }) + expect(second).toMatchObject({ + status: "selected", + providerId: first.providerId, + revision: 2, + }) + await expect( + prisma.authenticationProvider.findUnique({ + where: { id: first.providerId }, + include: { selectedRevision: true, revisions: { orderBy: { revision: "asc" } } }, + }), + ).resolves.toMatchObject({ + selectedRevision: { id: second.revisionId, revision: 2 }, + revisions: [{ revision: 1 }, { revision: 2 }], + }) + await expect(prisma.securityAudit.count()).resolves.toBe(2) + }) + + it("returns an unchanged selected revision only for the same digest", async () => { + const first = await persistValidatedProviderRevision(revision(1), dependencies()) + const unchanged = await persistValidatedProviderRevision(revision(1), dependencies()) + + expect(unchanged).toEqual({ ...first, status: "unchanged" }) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1) + await expect(prisma.securityAudit.count()).resolves.toBe(1) + }) + + it("rejects rollback, same-revision drift, and revision gaps after rollback", async () => { + await persistValidatedProviderRevision(revision(1), dependencies()) + await persistValidatedProviderRevision(revision(2), dependencies()) + + await expect( + persistValidatedProviderRevision(revision(1), dependencies()), + ).rejects.toBeInstanceOf(ProviderRevisionRollbackError) + await expect( + persistValidatedProviderRevision(revision(2, "changed"), dependencies()), + ).rejects.toBeInstanceOf(ProviderRevisionDriftError) + await expect( + persistValidatedProviderRevision(revision(4), dependencies()), + ).rejects.toBeInstanceOf(ProviderRevisionGapError) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) + const deniedAudits = await prisma.securityAudit.findMany({ + where: { outcome: "DENIED" }, + orderBy: { createdAt: "asc" }, + }) + expect(deniedAudits.map(({ context }) => context)).toEqual([ + { provider: "workforce", revision: 1, reason: "rollback" }, + { provider: "workforce", revision: 2, reason: "same_revision_drift" }, + { provider: "workforce", revision: 4, reason: "revision_gap" }, + ]) + }) + + it("rolls back a partially written revision before recording its failure audit", async () => { + await persistValidatedProviderRevision(revision(1), dependencies()) + const base = dependencies() + async function runTransaction( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ): Promise { + return prisma.$transaction( + (transaction) => + operation({ + createAuthenticationProvider: (input) => + createAuthenticationProvider(transaction, input), + createAuthenticationProviderRevision: (input) => + createAuthenticationProviderRevision(transaction, input), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), + selectAuthenticationProviderRevision: () => + Promise.reject(new Error("injected selection failure")), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + } + + await expect( + persistValidatedProviderRevision(revision(2), { ...base, runTransaction }), + ).rejects.toThrow("injected selection failure") + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1) + await expect( + prisma.authenticationProvider.findUnique({ + where: { key: "workforce" }, + include: { selectedRevision: true }, + }), + ).resolves.toMatchObject({ selectedRevision: { revision: 1 } }) + const failureAudit = await prisma.securityAudit.findFirst({ + where: { outcome: "FAILURE" }, + }) + expect(failureAudit?.action).toBe("identity.provider_revision.persist_failed") + expect(failureAudit?.context).toEqual({ + provider: "workforce", + revision: 2, + reason: "transaction_error", + }) + }) + + it("overlaps competing next revisions and commits exactly one complete update", async () => { + await persistValidatedProviderRevision(revision(1), dependencies()) + const runTransaction = controlledConcurrentTransaction() + const first = persistValidatedProviderRevision(revision(2, "first"), { + ...dependencies(), + runTransaction, + }) + const second = persistValidatedProviderRevision(revision(2, "second"), { + ...dependencies(), + runTransaction, + }) + + const results = await Promise.allSettled([first, second]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect( + results.filter( + (result) => + result.status === "rejected" && result.reason instanceof ProviderRevisionConflictError, + ), + ).toHaveLength(1) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) + const audits = await prisma.securityAudit.groupBy({ + by: ["outcome"], + _count: { _all: true }, + }) + expect(Object.fromEntries(audits.map(({ outcome, _count }) => [outcome, _count._all]))).toEqual( + { + SUCCESS: 2, + FAILURE: 1, + }, + ) + }) +}) diff --git a/hub/server/domains/identity/persist-validated-provider-revision/index.ts b/hub/server/domains/identity/persist-validated-provider-revision/index.ts new file mode 100644 index 00000000..52843215 --- /dev/null +++ b/hub/server/domains/identity/persist-validated-provider-revision/index.ts @@ -0,0 +1,117 @@ +import { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" +import { + IdentityTransactionConflictError, + ProviderRevisionConflictError, + ProviderRevisionDriftError, + ProviderRevisionGapError, + ProviderRevisionInvariantError, + ProviderRevisionRollbackError, +} from "../errors.ts" +import { getSelectedProviderRevision } from "../get-selected-provider-revision/index.ts" +import type { + PersistValidatedProviderRevisionDependencies, + PersistValidatedProviderRevisionInput, + PersistValidatedProviderRevisionResult, +} from "./types.ts" + +function denialReason(error: unknown) { + if (error instanceof ProviderRevisionRollbackError) return "rollback" + if (error instanceof ProviderRevisionDriftError) return "same_revision_drift" + if (error instanceof ProviderRevisionGapError) return "revision_gap" + if (error instanceof ProviderRevisionInvariantError) return "invariant" + return null +} + +export async function persistValidatedProviderRevision( + input: PersistValidatedProviderRevisionInput, + dependencies: PersistValidatedProviderRevisionDependencies, +): Promise { + const providerId = dependencies.createId() + const revisionId = dependencies.createId() + const successAuditId = dependencies.createId() + + try { + return await dependencies.runTransaction(async (repositories) => { + const current = await getSelectedProviderRevision(input.providerKey, repositories) + let selectedProviderId = providerId + + if (current) { + selectedProviderId = current.providerId + if (input.revision < current.revision) throw new ProviderRevisionRollbackError() + if (input.revision === current.revision) { + if (input.configurationDigest !== current.configurationDigest) { + throw new ProviderRevisionDriftError() + } + return { + status: "unchanged", + providerId: current.providerId, + revisionId: current.id, + revision: current.revision, + } + } + if (input.revision !== current.revision + 1) throw new ProviderRevisionGapError() + } else { + if (input.revision !== 1) throw new ProviderRevisionGapError() + await repositories.createAuthenticationProvider({ + id: providerId, + key: input.providerKey, + }) + } + + await repositories.createAuthenticationProviderRevision({ + id: revisionId, + providerId: selectedProviderId, + revision: input.revision, + configurationDigest: input.configurationDigest, + settings: input.settings, + }) + await repositories.selectAuthenticationProviderRevision({ + providerId: selectedProviderId, + revisionId, + }) + await repositories.createSecurityAudit({ + id: successAuditId, + action: "identity.provider_revision.selected", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "authentication_provider", + targetId: selectedProviderId, + context: { provider: input.providerKey, revision: input.revision }, + }) + + return { + status: "selected", + providerId: selectedProviderId, + revisionId, + revision: input.revision, + } + }) + } catch (error) { + const reason = denialReason(error) + const conflict = error instanceof IdentityTransactionConflictError + const auditError = conflict ? new ProviderRevisionConflictError({ cause: error }) : error + try { + await dependencies.writeAudit({ + id: dependencies.createId(), + action: conflict + ? "identity.provider_revision.persist_failed" + : reason + ? "identity.provider_revision.denied" + : "identity.provider_revision.persist_failed", + outcome: conflict || !reason ? SecurityAuditOutcome.FAILURE : SecurityAuditOutcome.DENIED, + targetType: "authentication_provider", + context: { + provider: input.providerKey, + revision: input.revision, + reason: reason ?? (conflict ? "collision" : "transaction_error"), + }, + }) + } catch (failureAuditError) { + throw new AggregateError( + [auditError, failureAuditError], + "The provider revision rejection and its audit could not both be recorded.", + { cause: failureAuditError }, + ) + } + throw auditError + } +} diff --git a/hub/server/domains/identity/persist-validated-provider-revision/types.ts b/hub/server/domains/identity/persist-validated-provider-revision/types.ts new file mode 100644 index 00000000..f8414938 --- /dev/null +++ b/hub/server/domains/identity/persist-validated-provider-revision/types.ts @@ -0,0 +1,15 @@ +import type { CreateSecurityAuditInput } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { ValidatedProviderRevision } from "../create-validated-provider-revision/types.ts" +import type { ProviderRevisionTransaction } from "../run-provider-revision-transaction/types.ts" + +export interface PersistValidatedProviderRevisionDependencies { + createId(): string + runTransaction: ProviderRevisionTransaction + writeAudit(input: CreateSecurityAuditInput): Promise +} + +export type PersistValidatedProviderRevisionResult = + | { status: "selected"; providerId: string; revisionId: string; revision: number } + | { status: "unchanged"; providerId: string; revisionId: string; revision: number } + +export type PersistValidatedProviderRevisionInput = ValidatedProviderRevision diff --git a/hub/server/domains/identity/resolve-external-identity/index.ts b/hub/server/domains/identity/resolve-external-identity/index.ts new file mode 100644 index 00000000..4acef9de --- /dev/null +++ b/hub/server/domains/identity/resolve-external-identity/index.ts @@ -0,0 +1,22 @@ +import { z } from "zod" + +import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" +import type { ResolveExternalIdentityDependencies, ResolveExternalIdentityResult } from "./types.ts" + +const externalIdentityTupleSchema = z.object({ + provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + issuer: z.string().min(1).max(500), + subject: z.string().min(1).max(500), +}) + +export async function resolveExternalIdentity( + input: ExternalIdentityTuple, + dependencies: ResolveExternalIdentityDependencies, +): Promise { + const tuple = externalIdentityTupleSchema.parse(input) + const identity = await dependencies.findExternalIdentity(tuple) + + return identity + ? { status: "found", externalIdentityId: identity.id, userId: identity.userId } + : { status: "absent" } +} diff --git a/hub/server/domains/identity/resolve-external-identity/types.ts b/hub/server/domains/identity/resolve-external-identity/types.ts new file mode 100644 index 00000000..c0a1a315 --- /dev/null +++ b/hub/server/domains/identity/resolve-external-identity/types.ts @@ -0,0 +1,8 @@ +import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" + +export interface ResolveExternalIdentityDependencies { + findExternalIdentity(tuple: ExternalIdentityTuple): Promise<{ id: string; userId: string } | null> +} + +export type ResolveExternalIdentityResult = + { status: "absent" } | { status: "found"; externalIdentityId: string; userId: string } diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts new file mode 100644 index 00000000..94df36ae --- /dev/null +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts @@ -0,0 +1,199 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { Prisma } from "../../../generated/prisma/client" +import { prisma } from "../../../integrations/prisma" +import { createExternalIdentity } from "../../../repositories/external-identities/create-external-identity" +import { findExternalIdentity } from "../../../repositories/external-identities/find-external-identity" +import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit" +import { createUser } from "../../../repositories/users/create-user" +import { ExternalIdentityCollisionError, IdentityTransactionConflictError } from "../errors" +import { runIdentityCreationTransaction } from "../run-identity-creation-transaction" +import type { + IdentityCreationRepositories, + IdentityCreationTransaction, +} from "../run-identity-creation-transaction/types" +import { resolveOrCreateExternalIdentity } from "." + +const tuple = { + provider: "workforce", + issuer: "https://id.example.test", + subject: "person-123", +} + +function dependencies(createId = randomUUID) { + return { + createId, + runTransaction: ( + operation: (repositories: IdentityCreationRepositories) => Promise, + ) => runIdentityCreationTransaction(prisma, operation), + writeAudit: (input: Parameters[1]) => + createSecurityAudit(prisma, input), + } +} + +function controlledConcurrentTransaction(): IdentityCreationTransaction { + let arrivals = 0 + let release: (() => void) | undefined + const overlap = new Promise((resolve) => { + release = resolve + }) + + return async (operation) => { + try { + return await prisma.$transaction( + (transaction) => + operation({ + createExternalIdentity: (input) => createExternalIdentity(transaction, input), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + createUser: (input) => createUser(transaction, input), + findExternalIdentity: async (input) => { + const identity = await findExternalIdentity(transaction, input) + if (!identity) { + arrivals += 1 + if (arrivals === 2) release?.() + await overlap + } + return identity + }, + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + ["P2002", "P2003", "P2034"].includes(error.code) + ) { + throw new IdentityTransactionConflictError({ cause: error }) + } + throw error + } + } +} + +describe("resolveOrCreateExternalIdentity()", () => { + it("atomically creates a user, immutable external identity, and success audit", async () => { + const result = await resolveOrCreateExternalIdentity(tuple, dependencies()) + + expect(result.status).toBe("created") + await expect(prisma.user.findMany()).resolves.toEqual([ + expect.objectContaining({ id: result.userId }), + ]) + await expect(prisma.externalIdentity.findMany()).resolves.toEqual([ + expect.objectContaining({ + id: result.externalIdentityId, + userId: result.userId, + ...tuple, + }), + ]) + await expect(prisma.securityAudit.findMany()).resolves.toEqual([ + expect.objectContaining({ + action: "identity.external.created", + outcome: "SUCCESS", + targetId: result.externalIdentityId, + context: { provider: tuple.provider, issuer: tuple.issuer }, + }), + ]) + }) + + it("resolves an existing immutable tuple without creating another user or audit", async () => { + const created = await resolveOrCreateExternalIdentity(tuple, dependencies()) + const resolved = await resolveOrCreateExternalIdentity(tuple, dependencies()) + + expect(resolved).toEqual({ ...created, status: "found" }) + await expect(prisma.user.count()).resolves.toBe(1) + await expect(prisma.externalIdentity.count()).resolves.toBe(1) + await expect(prisma.securityAudit.count()).resolves.toBe(1) + }) + + it("rolls back a user collision before writing a bounded failure audit", async () => { + const collidedUserId = randomUUID() + await createUser(prisma, { id: collidedUserId }) + const ids = [collidedUserId, randomUUID(), randomUUID(), randomUUID()][Symbol.iterator]() + + await expect( + resolveOrCreateExternalIdentity( + tuple, + dependencies(() => ids.next().value!), + ), + ).rejects.toBeInstanceOf(ExternalIdentityCollisionError) + await expect(prisma.user.count()).resolves.toBe(1) + await expect(prisma.externalIdentity.count()).resolves.toBe(0) + await expect(prisma.securityAudit.findMany()).resolves.toEqual([ + expect.objectContaining({ + action: "identity.external.create_failed", + outcome: "FAILURE", + context: { + provider: tuple.provider, + issuer: tuple.issuer, + reason: "collision", + }, + }), + ]) + }) + + it("rolls back partial state before auditing an unexpected transaction failure", async () => { + const base = dependencies() + async function runTransaction( + operation: (repositories: IdentityCreationRepositories) => Promise, + ): Promise { + return prisma.$transaction( + (transaction) => + operation({ + createExternalIdentity: () => Promise.reject(new Error("injected failure")), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + createUser: (input) => createUser(transaction, input), + findExternalIdentity: (input) => findExternalIdentity(transaction, input), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + } + + await expect( + resolveOrCreateExternalIdentity(tuple, { ...base, runTransaction }), + ).rejects.toThrow("injected failure") + await expect(prisma.user.count()).resolves.toBe(0) + await expect(prisma.externalIdentity.count()).resolves.toBe(0) + const audits = await prisma.securityAudit.findMany() + expect(audits).toHaveLength(1) + expect(audits[0]?.action).toBe("identity.external.create_failed") + expect(audits[0]?.outcome).toBe("FAILURE") + expect(audits[0]?.context).toEqual({ + provider: tuple.provider, + issuer: tuple.issuer, + reason: "transaction_error", + }) + }) + + it("overlaps competing creations and commits exactly one complete identity", async () => { + const runTransaction = controlledConcurrentTransaction() + const first = resolveOrCreateExternalIdentity(tuple, { + ...dependencies(), + runTransaction, + }) + const second = resolveOrCreateExternalIdentity(tuple, { + ...dependencies(), + runTransaction, + }) + + const results = await Promise.allSettled([first, second]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect( + results.filter( + (result) => + result.status === "rejected" && result.reason instanceof ExternalIdentityCollisionError, + ), + ).toHaveLength(1) + await expect(prisma.user.count()).resolves.toBe(1) + await expect(prisma.externalIdentity.count()).resolves.toBe(1) + await expect( + prisma.securityAudit.groupBy({ by: ["outcome"], _count: { _all: true } }), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ outcome: "SUCCESS", _count: { _all: 1 } }), + expect.objectContaining({ outcome: "FAILURE", _count: { _all: 1 } }), + ]), + ) + }) +}) diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/index.ts b/hub/server/domains/identity/resolve-or-create-external-identity/index.ts new file mode 100644 index 00000000..a13cc14f --- /dev/null +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.ts @@ -0,0 +1,65 @@ +import { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" +import { ExternalIdentityCollisionError, IdentityTransactionConflictError } from "../errors.ts" +import { resolveExternalIdentity } from "../resolve-external-identity/index.ts" +import type { + ResolveOrCreateExternalIdentityDependencies, + ResolveOrCreateExternalIdentityInput, + ResolveOrCreateExternalIdentityResult, +} from "./types.ts" + +export async function resolveOrCreateExternalIdentity( + input: ResolveOrCreateExternalIdentityInput, + dependencies: ResolveOrCreateExternalIdentityDependencies, +): Promise { + const userId = dependencies.createId() + const externalIdentityId = dependencies.createId() + const successAuditId = dependencies.createId() + + try { + return await dependencies.runTransaction(async (repositories) => { + const existing = await resolveExternalIdentity(input, repositories) + if (existing.status === "found") { + return { ...existing, status: "found" } + } + + await repositories.createUser({ id: userId }) + await repositories.createExternalIdentity({ id: externalIdentityId, userId, ...input }) + await repositories.createSecurityAudit({ + id: successAuditId, + action: "identity.external.created", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "external_identity", + targetId: externalIdentityId, + context: { provider: input.provider, issuer: input.issuer }, + }) + + return { status: "created", externalIdentityId, userId } + }) + } catch (error) { + const failure = + error instanceof IdentityTransactionConflictError + ? new ExternalIdentityCollisionError({ cause: error }) + : error + try { + await dependencies.writeAudit({ + id: dependencies.createId(), + action: "identity.external.create_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "external_identity", + context: { + provider: input.provider, + issuer: input.issuer, + reason: + error instanceof IdentityTransactionConflictError ? "collision" : "transaction_error", + }, + }) + } catch (auditError) { + throw new AggregateError( + [failure, auditError], + "The external identity failure and its audit could not both be recorded.", + { cause: auditError }, + ) + } + throw failure + } +} diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/types.ts b/hub/server/domains/identity/resolve-or-create-external-identity/types.ts new file mode 100644 index 00000000..6e0721b2 --- /dev/null +++ b/hub/server/domains/identity/resolve-or-create-external-identity/types.ts @@ -0,0 +1,17 @@ +import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" +import type { CreateSecurityAuditInput } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { IdentityCreationTransaction } from "../run-identity-creation-transaction/types.ts" + +export interface ResolveOrCreateExternalIdentityDependencies { + createId(): string + runTransaction: IdentityCreationTransaction + writeAudit(input: CreateSecurityAuditInput): Promise +} + +export interface ResolveOrCreateExternalIdentityResult { + externalIdentityId: string + status: "created" | "found" + userId: string +} + +export type ResolveOrCreateExternalIdentityInput = ExternalIdentityTuple diff --git a/hub/server/domains/identity/run-identity-creation-transaction/index.ts b/hub/server/domains/identity/run-identity-creation-transaction/index.ts new file mode 100644 index 00000000..abd5cba9 --- /dev/null +++ b/hub/server/domains/identity/run-identity-creation-transaction/index.ts @@ -0,0 +1,33 @@ +import { Prisma } from "../../../generated/prisma/client.ts" +import { createExternalIdentity } from "../../../repositories/external-identities/create-external-identity/index.ts" +import { findExternalIdentity } from "../../../repositories/external-identities/find-external-identity/index.ts" +import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" +import { createUser } from "../../../repositories/users/create-user/index.ts" +import { IdentityTransactionConflictError } from "../errors.ts" +import type { IdentityCreationRepositories, IdentityDatabaseClient } from "./types.ts" + +export function runIdentityCreationTransaction( + database: IdentityDatabaseClient, + operation: (repositories: IdentityCreationRepositories) => Promise, +): Promise { + return database + .$transaction( + (transaction) => + operation({ + createExternalIdentity: (input) => createExternalIdentity(transaction, input), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + createUser: (input) => createUser(transaction, input), + findExternalIdentity: (tuple) => findExternalIdentity(transaction, tuple), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + ["P2002", "P2003", "P2034"].includes(error.code) + ) { + throw new IdentityTransactionConflictError({ cause: error }) + } + throw error + }) +} diff --git a/hub/server/domains/identity/run-identity-creation-transaction/types.ts b/hub/server/domains/identity/run-identity-creation-transaction/types.ts new file mode 100644 index 00000000..224efe5d --- /dev/null +++ b/hub/server/domains/identity/run-identity-creation-transaction/types.ts @@ -0,0 +1,18 @@ +import type { PrismaClient } from "../../../generated/prisma/client.ts" +import type { CreateExternalIdentityInput } from "../../../repositories/external-identities/create-external-identity/types.ts" +import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" +import type { CreateSecurityAuditInput } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { CreateUserInput } from "../../../repositories/users/create-user/types.ts" + +export interface IdentityCreationRepositories { + createExternalIdentity(input: CreateExternalIdentityInput): Promise + createSecurityAudit(input: CreateSecurityAuditInput): Promise + createUser(input: CreateUserInput): Promise + findExternalIdentity(tuple: ExternalIdentityTuple): Promise<{ id: string; userId: string } | null> +} + +export type IdentityCreationTransaction = ( + operation: (repositories: IdentityCreationRepositories) => Promise, +) => Promise + +export type IdentityDatabaseClient = Pick diff --git a/hub/server/domains/identity/run-provider-revision-transaction/index.ts b/hub/server/domains/identity/run-provider-revision-transaction/index.ts new file mode 100644 index 00000000..01353948 --- /dev/null +++ b/hub/server/domains/identity/run-provider-revision-transaction/index.ts @@ -0,0 +1,37 @@ +import { Prisma } from "../../../generated/prisma/client.ts" +import { createAuthenticationProviderRevision } from "../../../repositories/authentication-providers/create-authentication-provider-revision/index.ts" +import { createAuthenticationProvider } from "../../../repositories/authentication-providers/create-authentication-provider/index.ts" +import { findAuthenticationProvider } from "../../../repositories/authentication-providers/find-authentication-provider/index.ts" +import { selectAuthenticationProviderRevision } from "../../../repositories/authentication-providers/select-authentication-provider-revision/index.ts" +import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" +import { IdentityTransactionConflictError } from "../errors.ts" +import type { ProviderRevisionDatabaseClient, ProviderRevisionRepositories } from "./types.ts" + +export function runProviderRevisionTransaction( + database: ProviderRevisionDatabaseClient, + operation: (repositories: ProviderRevisionRepositories) => Promise, +): Promise { + return database + .$transaction( + (transaction) => + operation({ + createAuthenticationProvider: (input) => createAuthenticationProvider(transaction, input), + createAuthenticationProviderRevision: (input) => + createAuthenticationProviderRevision(transaction, input), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), + selectAuthenticationProviderRevision: (input) => + selectAuthenticationProviderRevision(transaction, input), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + ["P2002", "P2003", "P2034"].includes(error.code) + ) { + throw new IdentityTransactionConflictError({ cause: error }) + } + throw error + }) +} diff --git a/hub/server/domains/identity/run-provider-revision-transaction/types.ts b/hub/server/domains/identity/run-provider-revision-transaction/types.ts new file mode 100644 index 00000000..41655d86 --- /dev/null +++ b/hub/server/domains/identity/run-provider-revision-transaction/types.ts @@ -0,0 +1,37 @@ +import type { PrismaClient } from "../../../generated/prisma/client.ts" +import type { CreateAuthenticationProviderRevisionInput } from "../../../repositories/authentication-providers/create-authentication-provider-revision/types.ts" +import type { CreateAuthenticationProviderInput } from "../../../repositories/authentication-providers/create-authentication-provider/types.ts" +import type { SelectAuthenticationProviderRevisionInput } from "../../../repositories/authentication-providers/select-authentication-provider-revision/types.ts" +import type { CreateSecurityAuditInput } from "../../../repositories/security-audits/create-security-audit/types.ts" + +export interface SelectedProviderRevisionRecord { + id: string + providerId: string + revision: number + configurationDigest: string + settings: unknown +} + +export interface AuthenticationProviderRecord { + id: string + key: string + selectedRevision: SelectedProviderRevisionRecord | null +} + +export interface ProviderRevisionRepositories { + createAuthenticationProvider(input: CreateAuthenticationProviderInput): Promise + createAuthenticationProviderRevision( + input: CreateAuthenticationProviderRevisionInput, + ): Promise + createSecurityAudit(input: CreateSecurityAuditInput): Promise + findAuthenticationProvider(key: string): Promise + selectAuthenticationProviderRevision( + input: SelectAuthenticationProviderRevisionInput, + ): Promise +} + +export type ProviderRevisionTransaction = ( + operation: (repositories: ProviderRevisionRepositories) => Promise, +) => Promise + +export type ProviderRevisionDatabaseClient = Pick diff --git a/hub/server/repositories/authentication-providers/create-authentication-provider-revision/index.ts b/hub/server/repositories/authentication-providers/create-authentication-provider-revision/index.ts new file mode 100644 index 00000000..8dd9c597 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-authentication-provider-revision/index.ts @@ -0,0 +1,11 @@ +import type { + CreateAuthenticationProviderRevisionDatabase, + CreateAuthenticationProviderRevisionInput, +} from "./types.ts" + +export function createAuthenticationProviderRevision( + database: CreateAuthenticationProviderRevisionDatabase, + input: CreateAuthenticationProviderRevisionInput, +) { + return database.authenticationProviderRevision.create({ data: input }) +} diff --git a/hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts b/hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts new file mode 100644 index 00000000..b214d252 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts @@ -0,0 +1,14 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type CreateAuthenticationProviderRevisionDatabase = Pick< + Prisma.TransactionClient, + "authenticationProviderRevision" +> + +export interface CreateAuthenticationProviderRevisionInput { + id: string + providerId: string + revision: number + configurationDigest: string + settings: Prisma.InputJsonObject +} diff --git a/hub/server/repositories/authentication-providers/create-authentication-provider/index.ts b/hub/server/repositories/authentication-providers/create-authentication-provider/index.ts new file mode 100644 index 00000000..dd39bf42 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-authentication-provider/index.ts @@ -0,0 +1,11 @@ +import type { + CreateAuthenticationProviderDatabase, + CreateAuthenticationProviderInput, +} from "./types.ts" + +export function createAuthenticationProvider( + database: CreateAuthenticationProviderDatabase, + input: CreateAuthenticationProviderInput, +) { + return database.authenticationProvider.create({ data: input }) +} diff --git a/hub/server/repositories/authentication-providers/create-authentication-provider/types.ts b/hub/server/repositories/authentication-providers/create-authentication-provider/types.ts new file mode 100644 index 00000000..46ee0ff3 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-authentication-provider/types.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type CreateAuthenticationProviderDatabase = Pick< + Prisma.TransactionClient, + "authenticationProvider" +> + +export interface CreateAuthenticationProviderInput { + id: string + key: string +} diff --git a/hub/server/repositories/authentication-providers/find-authentication-provider/index.ts b/hub/server/repositories/authentication-providers/find-authentication-provider/index.ts new file mode 100644 index 00000000..b7c0ef68 --- /dev/null +++ b/hub/server/repositories/authentication-providers/find-authentication-provider/index.ts @@ -0,0 +1,11 @@ +import type { FindAuthenticationProviderDatabase } from "./types.ts" + +export function findAuthenticationProvider( + database: FindAuthenticationProviderDatabase, + key: string, +) { + return database.authenticationProvider.findUnique({ + where: { key }, + include: { selectedRevision: true }, + }) +} diff --git a/hub/server/repositories/authentication-providers/find-authentication-provider/types.ts b/hub/server/repositories/authentication-providers/find-authentication-provider/types.ts new file mode 100644 index 00000000..bfc27dfa --- /dev/null +++ b/hub/server/repositories/authentication-providers/find-authentication-provider/types.ts @@ -0,0 +1,6 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type FindAuthenticationProviderDatabase = Pick< + Prisma.TransactionClient, + "authenticationProvider" +> diff --git a/hub/server/repositories/authentication-providers/select-authentication-provider-revision/index.ts b/hub/server/repositories/authentication-providers/select-authentication-provider-revision/index.ts new file mode 100644 index 00000000..7d91567a --- /dev/null +++ b/hub/server/repositories/authentication-providers/select-authentication-provider-revision/index.ts @@ -0,0 +1,14 @@ +import type { + SelectAuthenticationProviderRevisionDatabase, + SelectAuthenticationProviderRevisionInput, +} from "./types.ts" + +export function selectAuthenticationProviderRevision( + database: SelectAuthenticationProviderRevisionDatabase, + input: SelectAuthenticationProviderRevisionInput, +) { + return database.authenticationProvider.update({ + where: { id: input.providerId }, + data: { selectedRevisionId: input.revisionId }, + }) +} diff --git a/hub/server/repositories/authentication-providers/select-authentication-provider-revision/types.ts b/hub/server/repositories/authentication-providers/select-authentication-provider-revision/types.ts new file mode 100644 index 00000000..c87906a4 --- /dev/null +++ b/hub/server/repositories/authentication-providers/select-authentication-provider-revision/types.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type SelectAuthenticationProviderRevisionDatabase = Pick< + Prisma.TransactionClient, + "authenticationProvider" +> + +export interface SelectAuthenticationProviderRevisionInput { + providerId: string + revisionId: string +} diff --git a/hub/server/repositories/external-identities/create-external-identity/index.ts b/hub/server/repositories/external-identities/create-external-identity/index.ts new file mode 100644 index 00000000..775c68fd --- /dev/null +++ b/hub/server/repositories/external-identities/create-external-identity/index.ts @@ -0,0 +1,8 @@ +import type { CreateExternalIdentityDatabase, CreateExternalIdentityInput } from "./types.ts" + +export function createExternalIdentity( + database: CreateExternalIdentityDatabase, + input: CreateExternalIdentityInput, +) { + return database.externalIdentity.create({ data: input }) +} diff --git a/hub/server/repositories/external-identities/create-external-identity/types.ts b/hub/server/repositories/external-identities/create-external-identity/types.ts new file mode 100644 index 00000000..ae513415 --- /dev/null +++ b/hub/server/repositories/external-identities/create-external-identity/types.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type CreateExternalIdentityDatabase = Pick + +export interface CreateExternalIdentityInput { + id: string + userId: string + provider: string + issuer: string + subject: string +} diff --git a/hub/server/repositories/external-identities/find-external-identity/index.ts b/hub/server/repositories/external-identities/find-external-identity/index.ts new file mode 100644 index 00000000..15dcca17 --- /dev/null +++ b/hub/server/repositories/external-identities/find-external-identity/index.ts @@ -0,0 +1,10 @@ +import type { ExternalIdentityTuple, FindExternalIdentityDatabase } from "./types.ts" + +export function findExternalIdentity( + database: FindExternalIdentityDatabase, + tuple: ExternalIdentityTuple, +) { + return database.externalIdentity.findUnique({ + where: { provider_issuer_subject: tuple }, + }) +} diff --git a/hub/server/repositories/external-identities/find-external-identity/types.ts b/hub/server/repositories/external-identities/find-external-identity/types.ts new file mode 100644 index 00000000..df78db0d --- /dev/null +++ b/hub/server/repositories/external-identities/find-external-identity/types.ts @@ -0,0 +1,9 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type FindExternalIdentityDatabase = Pick + +export interface ExternalIdentityTuple { + provider: string + issuer: string + subject: string +} diff --git a/hub/server/repositories/security-audits/create-security-audit/index.test.ts b/hub/server/repositories/security-audits/create-security-audit/index.test.ts new file mode 100644 index 00000000..aa1c55e3 --- /dev/null +++ b/hub/server/repositories/security-audits/create-security-audit/index.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest" + +import type { Prisma } from "../../../generated/prisma/client" +import { SecurityAuditOutcome } from "../../../generated/prisma/enums" +import { createSecurityAudit } from "." + +describe("createSecurityAudit()", () => { + it("passes a bounded context to the database", async () => { + const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() + create.mockResolvedValue(undefined) + + const input = { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + action: "identity.external.created", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "external_identity", + context: { provider: "workforce", revision: 1 }, + } + await createSecurityAudit({ securityAudit: { create } }, input) + + expect(create).toHaveBeenCalledWith({ data: input }) + }) + + it("rejects secret-bearing and oversized context before writing", () => { + const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() + + expect(() => + createSecurityAudit( + { securityAudit: { create } }, + { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + action: "identity.external.create_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "external_identity", + context: { accessToken: "must-not-persist" }, + }, + ), + ).toThrow("cannot contain secret-bearing fields") + expect(() => + createSecurityAudit( + { securityAudit: { create } }, + { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + action: "identity.external.create_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "external_identity", + context: { detail: "x".repeat(501) }, + }, + ), + ).toThrow("cannot exceed 500 characters") + expect(create).not.toHaveBeenCalled() + }) +}) diff --git a/hub/server/repositories/security-audits/create-security-audit/index.ts b/hub/server/repositories/security-audits/create-security-audit/index.ts new file mode 100644 index 00000000..9937e9e6 --- /dev/null +++ b/hub/server/repositories/security-audits/create-security-audit/index.ts @@ -0,0 +1,49 @@ +import type { + CreateSecurityAuditDatabase, + CreateSecurityAuditInput, + SecurityAuditContext, +} from "./types.ts" + +const sensitiveKey = /(?:authorization|cookie|credential|password|secret|token)/i + +function assertBoundedText(name: string, value: string, maximum: number) { + if (!value.length || value.length > maximum) { + throw new Error(`${name} must contain between 1 and ${maximum} characters.`) + } +} + +function validateContext(context: SecurityAuditContext) { + const entries = Object.entries(context) + if (entries.length > 16) { + throw new Error("Security audit context cannot contain more than 16 fields.") + } + for (const [key, value] of entries) { + assertBoundedText("Security audit context keys", key, 100) + if (sensitiveKey.test(key)) { + throw new Error("Security audit context cannot contain secret-bearing fields.") + } + if (typeof value === "string" && value.length > 500) { + throw new Error("Security audit context values cannot exceed 500 characters.") + } + if (typeof value === "number" && !Number.isFinite(value)) { + throw new Error("Security audit context numbers must be finite.") + } + } + if (Buffer.byteLength(JSON.stringify(context), "utf8") > 4_096) { + throw new Error("Security audit context cannot exceed 4096 bytes.") + } +} + +export function createSecurityAudit( + database: CreateSecurityAuditDatabase, + input: CreateSecurityAuditInput, +) { + assertBoundedText("Security audit action", input.action, 100) + assertBoundedText("Security audit target type", input.targetType, 100) + if (input.targetId !== undefined) { + assertBoundedText("Security audit target ID", input.targetId, 100) + } + validateContext(input.context) + + return database.securityAudit.create({ data: input }) +} diff --git a/hub/server/repositories/security-audits/create-security-audit/types.ts b/hub/server/repositories/security-audits/create-security-audit/types.ts new file mode 100644 index 00000000..17cf81a4 --- /dev/null +++ b/hub/server/repositories/security-audits/create-security-audit/types.ts @@ -0,0 +1,20 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" + +export interface CreateSecurityAuditDatabase { + securityAudit: { + create(input: { data: Prisma.SecurityAuditCreateInput }): Promise + } +} + +export type SecurityAuditContextValue = boolean | number | string | null +export type SecurityAuditContext = Record + +export interface CreateSecurityAuditInput { + id: string + action: string + outcome: SecurityAuditOutcome + targetType: string + targetId?: string + context: SecurityAuditContext +} diff --git a/hub/server/repositories/users/create-user/index.ts b/hub/server/repositories/users/create-user/index.ts new file mode 100644 index 00000000..97a139f2 --- /dev/null +++ b/hub/server/repositories/users/create-user/index.ts @@ -0,0 +1,5 @@ +import type { CreateUserDatabase, CreateUserInput } from "./types.ts" + +export function createUser(database: CreateUserDatabase, input: CreateUserInput) { + return database.user.create({ data: input }) +} diff --git a/hub/server/repositories/users/create-user/types.ts b/hub/server/repositories/users/create-user/types.ts new file mode 100644 index 00000000..3d806632 --- /dev/null +++ b/hub/server/repositories/users/create-user/types.ts @@ -0,0 +1,7 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type CreateUserDatabase = Pick + +export interface CreateUserInput { + id: string +} diff --git a/hub/test/database/global-setup/index.ts b/hub/test/database/global-setup/index.ts index f6a7ddf6..a0a6ef07 100644 --- a/hub/test/database/global-setup/index.ts +++ b/hub/test/database/global-setup/index.ts @@ -55,8 +55,8 @@ export default async function setup(project: TestProject) { process.execPath, [ join(hubRoot, "node_modules/prisma/build/index.js"), - "db", - "push", + "migrate", + "deploy", "--config", join(hubRoot, "prisma7.config.ts"), ], diff --git a/hub/test/database/identity-foundation/index.db.test.ts b/hub/test/database/identity-foundation/index.db.test.ts new file mode 100644 index 00000000..605670d5 --- /dev/null +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { prisma } from "../../../server/integrations/prisma" +import { createExternalIdentity } from "../../../server/repositories/external-identities/create-external-identity" +import { createUser } from "../../../server/repositories/users/create-user" + +describe("identity foundation migration", () => { + it("deploys the committed baseline with only C1 application tables", async () => { + const migrations = await prisma.$queryRaw>` + SELECT migration_name + FROM public._prisma_migrations + WHERE finished_at IS NOT NULL + ORDER BY started_at + ` + const tables = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + AND table_name <> '_prisma_migrations' + ORDER BY table_name + ` + + expect(migrations).toEqual([{ migration_name: "20261029203000_identity_foundation" }]) + expect(tables).toEqual([ + { table_name: "authentication_provider_revisions" }, + { table_name: "authentication_providers" }, + { table_name: "external_identities" }, + { table_name: "security_audits" }, + { table_name: "users" }, + ]) + }) + + it("keeps the identity tuple unique and prevents reassignment", async () => { + const firstUserId = randomUUID() + const secondUserId = randomUUID() + const identityId = randomUUID() + await createUser(prisma, { id: firstUserId }) + await createUser(prisma, { id: secondUserId }) + await createExternalIdentity(prisma, { + id: identityId, + userId: firstUserId, + provider: "workforce", + issuer: "https://id.example.test", + subject: "person-123", + }) + + await expect( + prisma.externalIdentity.create({ + data: { + id: randomUUID(), + userId: secondUserId, + provider: "workforce", + issuer: "https://id.example.test", + subject: "person-123", + }, + }), + ).rejects.toMatchObject({ code: "P2002" }) + await expect( + prisma.externalIdentity.update({ + where: { id: identityId }, + data: { userId: secondUserId }, + }), + ).rejects.toThrow("External identity mappings are immutable") + }) + + it("installs the indexes that protect tuple and revision concurrency", async () => { + const indexes = await prisma.$queryRaw>` + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND indexname IN ( + 'external_identities_provider_issuer_subject_key', + 'authentication_provider_revisions_provider_id_revision_key', + 'authentication_providers_key_key' + ) + ORDER BY indexname + ` + + expect(indexes).toEqual([ + { indexname: "authentication_provider_revisions_provider_id_revision_key" }, + { indexname: "authentication_providers_key_key" }, + { indexname: "external_identities_provider_issuer_subject_key" }, + ]) + }) + + it("keeps provider revisions and security audits immutable", async () => { + const providerId = randomUUID() + const revisionId = randomUUID() + const auditId = randomUUID() + await prisma.authenticationProvider.create({ + data: { id: providerId, key: "workforce" }, + }) + await prisma.authenticationProviderRevision.create({ + data: { + id: revisionId, + providerId, + revision: 1, + configurationDigest: "a".repeat(64), + settings: { issuer: "https://id.example.test" }, + }, + }) + await prisma.authenticationProvider.update({ + where: { id: providerId }, + data: { selectedRevisionId: revisionId }, + }) + await prisma.securityAudit.create({ + data: { + id: auditId, + action: "identity.provider_revision.selected", + outcome: "SUCCESS", + targetType: "authentication_provider", + targetId: providerId, + context: { revision: 1 }, + }, + }) + + await expect( + prisma.authenticationProviderRevision.update({ + where: { id: revisionId }, + data: { settings: { issuer: "https://changed.example.test" } }, + }), + ).rejects.toThrow("Authentication provider revisions are immutable") + await expect(prisma.securityAudit.delete({ where: { id: auditId } })).rejects.toThrow( + "Security audit records are immutable", + ) + }) +}) From 659e207880781b0df72eda6c6e1853f852293449 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 14:13:17 -0700 Subject: [PATCH 2/8] fix(hub): harden identity foundation invariants Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../authentication-provider-revision.prisma | 22 +++ hub/schema/authentication-provider.prisma | 23 --- .../migration.sql | 25 +-- .../index.test.ts | 67 +++++++- .../index.ts | 82 ++++------ .../create-security-audit/index.test.ts | 67 +++++++- .../create-security-audit/index.ts | 53 ++++--- .../create-security-audit/types.ts | 8 +- .../utils/normalize-secret-free-json/index.ts | 123 +++++++++++++++ .../utils/normalize-secret-free-json/types.ts | 17 ++ .../identity-foundation/index.db.test.ts | 146 +++++++++++++++++- 11 files changed, 492 insertions(+), 141 deletions(-) create mode 100644 hub/schema/authentication-provider-revision.prisma create mode 100644 hub/server/utils/normalize-secret-free-json/index.ts create mode 100644 hub/server/utils/normalize-secret-free-json/types.ts diff --git a/hub/schema/authentication-provider-revision.prisma b/hub/schema/authentication-provider-revision.prisma new file mode 100644 index 00000000..fd501c32 --- /dev/null +++ b/hub/schema/authentication-provider-revision.prisma @@ -0,0 +1,22 @@ +model AuthenticationProviderRevision { + // ID columns + id String @id @default(uuid()) @db.Uuid + providerId String @map("provider_id") @db.Uuid + + // Relations + provider AuthenticationProvider @relation("AuthenticationProviderRevisions", fields: [providerId], references: [id], onDelete: Restrict) + selectedBy AuthenticationProvider? @relation("SelectedAuthenticationProviderRevision") + + // Attribute columns + revision Int + configurationDigest String @map("configuration_digest") @db.VarChar(64) + settings Json @db.JsonB + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([providerId, revision]) + @@index([providerId]) + @@map("authentication_provider_revisions") +} diff --git a/hub/schema/authentication-provider.prisma b/hub/schema/authentication-provider.prisma index e523a4f2..10a5cbda 100644 --- a/hub/schema/authentication-provider.prisma +++ b/hub/schema/authentication-provider.prisma @@ -16,26 +16,3 @@ model AuthenticationProvider { @@map("authentication_providers") } - -model AuthenticationProviderRevision { - // ID columns - id String @id @default(uuid()) @db.Uuid - providerId String @map("provider_id") @db.Uuid - - // Relations - provider AuthenticationProvider @relation("AuthenticationProviderRevisions", fields: [providerId], references: [id], onDelete: Restrict) - selectedBy AuthenticationProvider? @relation("SelectedAuthenticationProviderRevision") - - // Attribute columns - revision Int - configurationDigest String @map("configuration_digest") @db.VarChar(64) - settings Json @db.JsonB - - // Timestamp columns - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) - - @@unique([providerId, revision]) - @@index([providerId]) - @@map("authentication_provider_revisions") -} diff --git a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql index 5e064821..e77db287 100644 --- a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql +++ b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql @@ -105,38 +105,25 @@ FOREIGN KEY ("selected_revision_id") REFERENCES "authentication_provider_revisio ON DELETE RESTRICT ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED; -CREATE FUNCTION prevent_external_identity_reassignment() +CREATE FUNCTION prevent_external_identity_changes() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN - IF NEW."user_id" <> OLD."user_id" - OR NEW."provider" <> OLD."provider" - OR NEW."issuer" <> OLD."issuer" - OR NEW."subject" <> OLD."subject" THEN - RAISE EXCEPTION 'External identity mappings are immutable.'; - END IF; - RETURN NEW; + RAISE EXCEPTION 'External identity records are immutable.'; END; $$; -CREATE TRIGGER "external_identities_immutable_mapping" -BEFORE UPDATE ON "external_identities" -FOR EACH ROW EXECUTE FUNCTION prevent_external_identity_reassignment(); +CREATE TRIGGER "external_identities_immutable" +BEFORE UPDATE OR DELETE ON "external_identities" +FOR EACH ROW EXECUTE FUNCTION prevent_external_identity_changes(); CREATE FUNCTION prevent_authentication_provider_revision_changes() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN - IF TG_OP = 'DELETE' - OR NEW."provider_id" <> OLD."provider_id" - OR NEW."revision" <> OLD."revision" - OR NEW."configuration_digest" <> OLD."configuration_digest" - OR NEW."settings" <> OLD."settings" THEN - RAISE EXCEPTION 'Authentication provider revisions are immutable.'; - END IF; - RETURN NEW; + RAISE EXCEPTION 'Authentication provider revisions are immutable.'; END; $$; diff --git a/hub/server/domains/identity/create-validated-provider-revision/index.test.ts b/hub/server/domains/identity/create-validated-provider-revision/index.test.ts index 72e3fcb2..e9c152d6 100644 --- a/hub/server/domains/identity/create-validated-provider-revision/index.test.ts +++ b/hub/server/domains/identity/create-validated-provider-revision/index.test.ts @@ -3,37 +3,88 @@ import { describe, expect, it } from "vitest" import { createValidatedProviderRevision } from "." describe("createValidatedProviderRevision()", () => { - it("creates the same digest for equivalent settings regardless of key order", () => { + it("creates the same digest for recursively equivalent settings regardless of key order", () => { const first = createValidatedProviderRevision({ providerKey: "workforce", revision: 1, - settings: { issuer: "https://id.example.test", audience: "hub" }, + settings: { + z: 1, + ä: 2, + nested: { issuer: "https://id.example.test", audience: "hub" }, + }, }) const second = createValidatedProviderRevision({ providerKey: "workforce", revision: 1, - settings: { audience: "hub", issuer: "https://id.example.test" }, + settings: { + nested: { audience: "hub", issuer: "https://id.example.test" }, + ä: 2, + z: 1, + }, }) expect(first.configurationDigest).toMatch(/^[0-9a-f]{64}$/) expect(second.configurationDigest).toBe(first.configurationDigest) expect(second.settings).toEqual(first.settings) + expect(Object.keys(first.settings)).toEqual(["nested", "z", "ä"]) }) - it("rejects credentials and settings that exceed the bounded contract", () => { + it("rejects secret-bearing keys and values at every supported nesting level", () => { expect(() => createValidatedProviderRevision({ providerKey: "workforce", revision: 1, - settings: { clientSecret: "must-not-persist" }, + settings: { privateKey: "must-not-persist" }, }), - ).toThrow("cannot contain credentials or secrets") + ).toThrow("cannot contain secret-bearing fields") + const secrets: Array> = [ + { databasePassword: "must-not-persist" }, + { clientSecret: "must-not-persist" }, + { accessToken: "must-not-persist" }, + { signingPrivateKey: "must-not-persist" }, + ] + for (const secret of secrets) { + expect(() => + createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { metadata: [secret] }, + }), + ).toThrow("cannot contain secret-bearing fields") + } expect(() => createValidatedProviderRevision({ providerKey: "workforce", revision: 1, - settings: { issuer: "x".repeat(2_001) }, + settings: { + certificate: [ + "-----BEGIN PRIVATE", + " KEY-----\nmaterial\n-----END PRIVATE", + " KEY-----", + ].join(""), + }, }), - ).toThrow("cannot exceed 2000 characters") + ).toThrow("cannot contain credential or key material") + }) + + it("allows non-secret key metadata and enforces recursion bounds", () => { + expect(() => + createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { + tokenEndpoint: "https://id.example.test/token", + keyId: "signing-key-1", + publicKeyUrl: "https://id.example.test/keys", + }, + }), + ).not.toThrow() + expect(() => + createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { a: { b: { c: { d: { e: "too-deep" } } } } }, + }), + ).toThrow("cannot exceed 4 nested levels") }) }) diff --git a/hub/server/domains/identity/create-validated-provider-revision/index.ts b/hub/server/domains/identity/create-validated-provider-revision/index.ts index 71157c7c..9d4cabd7 100644 --- a/hub/server/domains/identity/create-validated-provider-revision/index.ts +++ b/hub/server/domains/identity/create-validated-provider-revision/index.ts @@ -1,62 +1,11 @@ import { createHash } from "node:crypto" import { z } from "zod" -import type { Prisma } from "../../../generated/prisma/client.ts" +import { normalizeSecretFreeJson } from "../../../utils/normalize-secret-free-json/index.ts" import type { CreateValidatedProviderRevisionInput, ValidatedProviderRevision } from "./types.ts" -const sensitiveKey = /(?:authorization|cookie|credential|password|private.?key|secret|token)/i const providerKeySchema = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/) -function isJsonArray(value: Prisma.InputJsonValue): value is Prisma.InputJsonArray { - return Array.isArray(value) -} - -function normalizeSettingsObject(value: Prisma.InputJsonObject, depth = 0): Prisma.InputJsonObject { - if (depth > 4) throw new Error("Provider settings cannot exceed four nested levels.") - const entries = Object.entries(value) - if (entries.length > 32) throw new Error("Provider settings cannot exceed 32 fields per object.") - return Object.fromEntries( - entries - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => { - if (!key.length || key.length > 100) { - throw new Error("Provider setting keys must contain between 1 and 100 characters.") - } - if (sensitiveKey.test(key)) { - throw new Error("Provider settings cannot contain credentials or secrets.") - } - if (item === undefined) { - throw new Error("Provider settings cannot contain undefined values.") - } - return [key, normalizeSettingValue(item, depth + 1)] - }), - ) -} - -function normalizeSettingValue( - value: Prisma.InputJsonValue | null, - depth: number, -): Prisma.InputJsonValue | null { - if (depth > 4) throw new Error("Provider settings cannot exceed four nested levels.") - if (value === null || typeof value === "boolean") return value - if (typeof value === "number") { - if (!Number.isFinite(value)) throw new Error("Provider setting numbers must be finite.") - return value - } - if (typeof value === "string") { - if (value.length > 2_000) { - throw new Error("Provider setting strings cannot exceed 2000 characters.") - } - return value - } - if (isJsonArray(value)) { - if (value.length > 32) throw new Error("Provider setting arrays cannot exceed 32 items.") - return value.map((item) => normalizeSettingValue(item, depth + 1)) - } - if ("toJSON" in value) throw new Error("Provider settings must be plain JSON data.") - return normalizeSettingsObject(value, depth) -} - export function createValidatedProviderRevision( input: CreateValidatedProviderRevisionInput, ): ValidatedProviderRevision { @@ -64,11 +13,32 @@ export function createValidatedProviderRevision( if (!Number.isSafeInteger(input.revision) || input.revision < 1) { throw new Error("Provider revision must be a positive safe integer.") } - const settings = normalizeSettingsObject(input.settings) - const serialized = JSON.stringify(settings) - if (Buffer.byteLength(serialized, "utf8") > 16_384) { - throw new Error("Provider settings cannot exceed 16384 bytes.") + let settings + try { + settings = normalizeSecretFreeJson(input.settings, { + maxArrayItems: 32, + maxBytes: 16_384, + maxDepth: 4, + maxEntries: 128, + maxKeyLength: 100, + maxObjectFields: 32, + maxStringLength: 2_000, + }) + } catch (error) { + if (!(error instanceof Error)) throw error + throw new Error( + error.message + .replace("JSON data", "Provider settings") + .replace("JSON arrays", "Provider setting arrays") + .replace("JSON objects", "Provider setting objects") + .replace("JSON keys", "Provider setting keys") + .replace("JSON string values", "Provider setting values") + .replace("JSON numbers", "Provider setting numbers") + .replace("JSON values", "Provider setting values"), + { cause: error }, + ) } + const serialized = JSON.stringify(settings) return { providerKey, diff --git a/hub/server/repositories/security-audits/create-security-audit/index.test.ts b/hub/server/repositories/security-audits/create-security-audit/index.test.ts index aa1c55e3..85b6437f 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.test.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.test.ts @@ -21,7 +21,7 @@ describe("createSecurityAudit()", () => { expect(create).toHaveBeenCalledWith({ data: input }) }) - it("rejects secret-bearing and oversized context before writing", () => { + it("rejects secret-bearing keys and values recursively before writing", () => { const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() expect(() => @@ -32,10 +32,30 @@ describe("createSecurityAudit()", () => { action: "identity.external.create_failed", outcome: SecurityAuditOutcome.FAILURE, targetType: "external_identity", - context: { accessToken: "must-not-persist" }, + context: { privateKey: "must-not-persist" }, }, ), ).toThrow("cannot contain secret-bearing fields") + const secrets: Array> = [ + { databasePassword: "must-not-persist" }, + { clientSecret: "must-not-persist" }, + { accessToken: "must-not-persist" }, + { signingPrivateKey: "must-not-persist" }, + ] + for (const secret of secrets) { + expect(() => + createSecurityAudit( + { securityAudit: { create } }, + { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + action: "identity.external.create_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "external_identity", + context: { metadata: [secret] }, + }, + ), + ).toThrow("cannot contain secret-bearing fields") + } expect(() => createSecurityAudit( { securityAudit: { create } }, @@ -44,10 +64,49 @@ describe("createSecurityAudit()", () => { action: "identity.external.create_failed", outcome: SecurityAuditOutcome.FAILURE, targetType: "external_identity", - context: { detail: "x".repeat(501) }, + context: { + certificate: [ + "-----BEGIN PRIVATE", + " KEY-----\nmaterial\n-----END PRIVATE", + " KEY-----", + ].join(""), + }, }, ), - ).toThrow("cannot exceed 500 characters") + ).toThrow("cannot contain credential or key material") expect(create).not.toHaveBeenCalled() }) + + it("allows non-secret key metadata and rejects context beyond the recursion bound", async () => { + const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() + create.mockResolvedValue(undefined) + + await createSecurityAudit( + { securityAudit: { create } }, + { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + action: "identity.provider_revision.selected", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "authentication_provider", + context: { + tokenEndpoint: "https://id.example.test/token", + keyId: "signing-key-1", + publicKeyUrl: "https://id.example.test/keys", + }, + }, + ) + expect(() => + createSecurityAudit( + { securityAudit: { create } }, + { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + action: "identity.external.create_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "external_identity", + context: { a: { b: { c: { d: { e: "too-deep" } } } } }, + }, + ), + ).toThrow("cannot exceed 4 nested levels") + expect(create).toHaveBeenCalledTimes(1) + }) }) diff --git a/hub/server/repositories/security-audits/create-security-audit/index.ts b/hub/server/repositories/security-audits/create-security-audit/index.ts index 9937e9e6..8a328b45 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.ts @@ -1,10 +1,6 @@ -import type { - CreateSecurityAuditDatabase, - CreateSecurityAuditInput, - SecurityAuditContext, -} from "./types.ts" +import type { CreateSecurityAuditDatabase, CreateSecurityAuditInput } from "./types.ts" -const sensitiveKey = /(?:authorization|cookie|credential|password|secret|token)/i +import { normalizeSecretFreeJson } from "../../../utils/normalize-secret-free-json/index.ts" function assertBoundedText(name: string, value: string, maximum: number) { if (!value.length || value.length > maximum) { @@ -12,25 +8,28 @@ function assertBoundedText(name: string, value: string, maximum: number) { } } -function validateContext(context: SecurityAuditContext) { - const entries = Object.entries(context) - if (entries.length > 16) { - throw new Error("Security audit context cannot contain more than 16 fields.") - } - for (const [key, value] of entries) { - assertBoundedText("Security audit context keys", key, 100) - if (sensitiveKey.test(key)) { - throw new Error("Security audit context cannot contain secret-bearing fields.") - } - if (typeof value === "string" && value.length > 500) { - throw new Error("Security audit context values cannot exceed 500 characters.") - } - if (typeof value === "number" && !Number.isFinite(value)) { - throw new Error("Security audit context numbers must be finite.") - } - } - if (Buffer.byteLength(JSON.stringify(context), "utf8") > 4_096) { - throw new Error("Security audit context cannot exceed 4096 bytes.") +function validateContext(context: CreateSecurityAuditInput["context"]) { + try { + return normalizeSecretFreeJson(context, { + maxArrayItems: 16, + maxBytes: 4_096, + maxDepth: 4, + maxEntries: 64, + maxKeyLength: 100, + maxObjectFields: 16, + maxStringLength: 500, + }) + } catch (error) { + if (!(error instanceof Error)) throw error + const message = error.message + .replace("JSON data", "Security audit context") + .replace("JSON arrays", "Security audit context arrays") + .replace("JSON objects", "Security audit context objects") + .replace("JSON keys", "Security audit context keys") + .replace("JSON string values", "Security audit context values") + .replace("JSON numbers", "Security audit context numbers") + .replace("JSON values", "Security audit context values") + throw new Error(message, { cause: error }) } } @@ -43,7 +42,7 @@ export function createSecurityAudit( if (input.targetId !== undefined) { assertBoundedText("Security audit target ID", input.targetId, 100) } - validateContext(input.context) + const context = validateContext(input.context) - return database.securityAudit.create({ data: input }) + return database.securityAudit.create({ data: { ...input, context } }) } diff --git a/hub/server/repositories/security-audits/create-security-audit/types.ts b/hub/server/repositories/security-audits/create-security-audit/types.ts index 17cf81a4..f084226f 100644 --- a/hub/server/repositories/security-audits/create-security-audit/types.ts +++ b/hub/server/repositories/security-audits/create-security-audit/types.ts @@ -7,7 +7,13 @@ export interface CreateSecurityAuditDatabase { } } -export type SecurityAuditContextValue = boolean | number | string | null +export type SecurityAuditContextValue = + | boolean + | number + | string + | null + | SecurityAuditContextValue[] + | { [key: string]: SecurityAuditContextValue } export type SecurityAuditContext = Record export interface CreateSecurityAuditInput { diff --git a/hub/server/utils/normalize-secret-free-json/index.ts b/hub/server/utils/normalize-secret-free-json/index.ts new file mode 100644 index 00000000..1fa37f63 --- /dev/null +++ b/hub/server/utils/normalize-secret-free-json/index.ts @@ -0,0 +1,123 @@ +import type { + NormalizedJsonObject, + NormalizedJsonValue, + NormalizeSecretFreeJsonLimits, +} from "./types.ts" + +const sensitiveExactNames = new Set(["apikey", "authorization", "cookie"]) +const sensitiveNameSuffixes = [ + "accesstoken", + "apikey", + "clientkey", + "clientsecret", + "credential", + "credentials", + "idtoken", + "keymaterial", + "password", + "passphrase", + "passwd", + "privatekey", + "refreshtoken", + "secret", + "secretkey", + "sessionid", + "signingkey", + "token", +] +const sensitiveValuePatterns = [ + new RegExp("-----BEGIN [A-Z0-9 ]*PRIVATE" + " KEY-----", "i"), + /^(?:basic|bearer)\s+\S+/i, + /\b(?:password|passphrase|secret|access[_-]?token|refresh[_-]?token|private[_-]?key)\s*[:=]\s*\S+/i, + /\b(?:ghp_|github_pat_|AKIA)[A-Za-z0-9_/-]{16,}/, + /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, +] + +function compareOrdinal(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0 +} + +function isSensitiveName(key: string) { + const normalized = key.replaceAll(/[^a-z0-9]/gi, "").toLowerCase() + return ( + sensitiveExactNames.has(normalized) || + sensitiveNameSuffixes.some((suffix) => normalized.endsWith(suffix)) + ) +} + +function assertSafeString(value: string, maximum: number) { + if (value.length > maximum) { + throw new Error(`JSON string values cannot exceed ${maximum} characters.`) + } + if (sensitiveValuePatterns.some((pattern) => pattern.test(value))) { + throw new Error("JSON values cannot contain credential or key material.") + } +} + +export function normalizeSecretFreeJson( + value: unknown, + limits: NormalizeSecretFreeJsonLimits, +): NormalizedJsonObject { + let entries = 0 + + function visit(item: unknown, depth: number): NormalizedJsonValue { + if (depth > limits.maxDepth) { + throw new Error(`JSON data cannot exceed ${limits.maxDepth} nested levels.`) + } + if (item === null || typeof item === "boolean") return item + if (typeof item === "number") { + if (!Number.isFinite(item)) throw new Error("JSON numbers must be finite.") + return item + } + if (typeof item === "string") { + assertSafeString(item, limits.maxStringLength) + return item + } + if (Array.isArray(item)) { + if (item.length > limits.maxArrayItems) { + throw new Error(`JSON arrays cannot exceed ${limits.maxArrayItems} items.`) + } + entries += item.length + if (entries > limits.maxEntries) { + throw new Error(`JSON data cannot exceed ${limits.maxEntries} total entries.`) + } + return item.map((value) => visit(value, depth + 1)) + } + if (typeof item !== "object" || Object.getPrototypeOf(item) !== Object.prototype) { + throw new Error("JSON data must contain only plain objects, arrays, and scalar values.") + } + + const objectEntries = Object.entries(item) + if (objectEntries.length > limits.maxObjectFields) { + throw new Error(`JSON objects cannot exceed ${limits.maxObjectFields} fields.`) + } + entries += objectEntries.length + if (entries > limits.maxEntries) { + throw new Error(`JSON data cannot exceed ${limits.maxEntries} total entries.`) + } + return Object.fromEntries( + objectEntries + .sort(([left], [right]) => compareOrdinal(left, right)) + .map(([key, child]) => { + if (!key.length || key.length > limits.maxKeyLength) { + throw new Error( + `JSON keys must contain between 1 and ${limits.maxKeyLength} characters.`, + ) + } + if (isSensitiveName(key)) { + throw new Error("JSON data cannot contain secret-bearing fields.") + } + return [key, visit(child, depth + 1)] + }), + ) + } + + const normalized = visit(value, 0) + if (Array.isArray(normalized) || normalized === null || typeof normalized !== "object") { + throw new Error("JSON data must be an object.") + } + if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > limits.maxBytes) { + throw new Error(`JSON data cannot exceed ${limits.maxBytes} bytes.`) + } + return normalized +} diff --git a/hub/server/utils/normalize-secret-free-json/types.ts b/hub/server/utils/normalize-secret-free-json/types.ts new file mode 100644 index 00000000..afbb23c4 --- /dev/null +++ b/hub/server/utils/normalize-secret-free-json/types.ts @@ -0,0 +1,17 @@ +export type NormalizedJsonPrimitive = boolean | number | string | null +export type NormalizedJsonValue = + NormalizedJsonPrimitive | NormalizedJsonValue[] | NormalizedJsonObject + +export interface NormalizedJsonObject { + [key: string]: NormalizedJsonValue +} + +export interface NormalizeSecretFreeJsonLimits { + maxArrayItems: number + maxBytes: number + maxDepth: number + maxEntries: number + maxKeyLength: number + maxObjectFields: number + maxStringLength: number +} diff --git a/hub/test/database/identity-foundation/index.db.test.ts b/hub/test/database/identity-foundation/index.db.test.ts index 605670d5..2fd238fd 100644 --- a/hub/test/database/identity-foundation/index.db.test.ts +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -32,10 +32,11 @@ describe("identity foundation migration", () => { ]) }) - it("keeps the identity tuple unique and prevents reassignment", async () => { + it("keeps the identity tuple unique and rejects every identity history change", async () => { const firstUserId = randomUUID() const secondUserId = randomUUID() const identityId = randomUUID() + const auditId = randomUUID() await createUser(prisma, { id: firstUserId }) await createUser(prisma, { id: secondUserId }) await createExternalIdentity(prisma, { @@ -45,6 +46,16 @@ describe("identity foundation migration", () => { issuer: "https://id.example.test", subject: "person-123", }) + await prisma.securityAudit.create({ + data: { + id: auditId, + action: "identity.external.created", + outcome: "SUCCESS", + targetType: "external_identity", + targetId: identityId, + context: { provider: "workforce" }, + }, + }) await expect( prisma.externalIdentity.create({ @@ -62,7 +73,34 @@ describe("identity foundation migration", () => { where: { id: identityId }, data: { userId: secondUserId }, }), - ).rejects.toThrow("External identity mappings are immutable") + ).rejects.toThrow("External identity records are immutable") + await expect( + prisma.externalIdentity.update({ + where: { id: identityId }, + data: { id: randomUUID() }, + }), + ).rejects.toThrow("External identity records are immutable") + await expect( + prisma.externalIdentity.update({ + where: { id: identityId }, + data: { createdAt: new Date("2000-01-01T00:00:00Z") }, + }), + ).rejects.toThrow("External identity records are immutable") + await expect( + prisma.externalIdentity.update({ + where: { id: identityId }, + data: { updatedAt: new Date("2000-01-01T00:00:00Z") }, + }), + ).rejects.toThrow("External identity records are immutable") + await expect(prisma.externalIdentity.delete({ where: { id: identityId } })).rejects.toThrow( + "External identity records are immutable", + ) + await expect( + prisma.externalIdentity.findUnique({ where: { id: identityId } }), + ).resolves.toEqual(expect.objectContaining({ id: identityId, userId: firstUserId })) + await expect(prisma.securityAudit.findUnique({ where: { id: auditId } })).resolves.toEqual( + expect.objectContaining({ targetId: identityId }), + ) }) it("installs the indexes that protect tuple and revision concurrency", async () => { @@ -85,7 +123,7 @@ describe("identity foundation migration", () => { ]) }) - it("keeps provider revisions and security audits immutable", async () => { + it("keeps provider revisions and security audit history immutable", async () => { const providerId = randomUUID() const revisionId = randomUUID() const auditId = randomUUID() @@ -122,8 +160,110 @@ describe("identity foundation migration", () => { data: { settings: { issuer: "https://changed.example.test" } }, }), ).rejects.toThrow("Authentication provider revisions are immutable") + await expect( + prisma.authenticationProviderRevision.update({ + where: { id: revisionId }, + data: { id: randomUUID() }, + }), + ).rejects.toThrow("Authentication provider revisions are immutable") + await expect( + prisma.authenticationProviderRevision.update({ + where: { id: revisionId }, + data: { createdAt: new Date("2000-01-01T00:00:00Z") }, + }), + ).rejects.toThrow("Authentication provider revisions are immutable") + await expect( + prisma.authenticationProviderRevision.update({ + where: { id: revisionId }, + data: { updatedAt: new Date("2000-01-01T00:00:00Z") }, + }), + ).rejects.toThrow("Authentication provider revisions are immutable") + await expect( + prisma.authenticationProviderRevision.delete({ where: { id: revisionId } }), + ).rejects.toThrow("Authentication provider revisions are immutable") + await expect( + prisma.securityAudit.update({ + where: { id: auditId }, + data: { + id: randomUUID(), + createdAt: new Date("2000-01-01T00:00:00Z"), + updatedAt: new Date("2000-01-01T00:00:00Z"), + }, + }), + ).rejects.toThrow("Security audit records are immutable") await expect(prisma.securityAudit.delete({ where: { id: auditId } })).rejects.toThrow( "Security audit records are immutable", ) + const provider = await prisma.authenticationProvider.findUnique({ + where: { id: providerId }, + include: { selectedRevision: true }, + }) + expect(provider?.selectedRevisionId).toBe(revisionId) + expect(provider?.selectedRevision?.id).toBe(revisionId) + }) + + it("rejects foreign-key violations and selecting another provider's revision", async () => { + const firstProviderId = randomUUID() + const secondProviderId = randomUUID() + const secondRevisionId = randomUUID() + await prisma.authenticationProvider.createMany({ + data: [ + { id: firstProviderId, key: "first" }, + { id: secondProviderId, key: "second" }, + ], + }) + await prisma.authenticationProviderRevision.create({ + data: { + id: secondRevisionId, + providerId: secondProviderId, + revision: 1, + configurationDigest: "b".repeat(64), + settings: { issuer: "https://id.example.test" }, + }, + }) + + await expect( + prisma.externalIdentity.create({ + data: { + id: randomUUID(), + userId: randomUUID(), + provider: "workforce", + issuer: "https://id.example.test", + subject: "missing-user", + }, + }), + ).rejects.toMatchObject({ code: "P2003" }) + await expect( + prisma.authenticationProviderRevision.create({ + data: { + id: randomUUID(), + providerId: randomUUID(), + revision: 1, + configurationDigest: "c".repeat(64), + settings: {}, + }, + }), + ).rejects.toMatchObject({ code: "P2003" }) + await expect( + prisma.authenticationProvider.update({ + where: { id: firstProviderId }, + data: { selectedRevisionId: secondRevisionId }, + }), + ).rejects.toThrow("Selected revision must belong to its authentication provider") + }) + + it("rejects audit context that exceeds the migration byte bound", async () => { + await expect( + prisma.securityAudit.create({ + data: { + id: randomUUID(), + action: "identity.external.create_failed", + outcome: "FAILURE", + targetType: "external_identity", + context: { detail: "x".repeat(5_000) }, + }, + }), + ).rejects.toThrow() + await expect(prisma.securityAudit.count()).resolves.toBe(0) }) }) From ad685abe0b9113c79e369f471260b8c97e6e24f6 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 14:40:27 -0700 Subject: [PATCH 3/8] test(hub): cover identity JSON limits Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../index.test.ts | 26 ++++ .../normalize-secret-free-json/index.test.ts | 111 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 hub/server/utils/normalize-secret-free-json/index.test.ts diff --git a/hub/server/domains/identity/create-validated-provider-revision/index.test.ts b/hub/server/domains/identity/create-validated-provider-revision/index.test.ts index e9c152d6..562925e5 100644 --- a/hub/server/domains/identity/create-validated-provider-revision/index.test.ts +++ b/hub/server/domains/identity/create-validated-provider-revision/index.test.ts @@ -87,4 +87,30 @@ describe("createValidatedProviderRevision()", () => { }), ).toThrow("cannot exceed 4 nested levels") }) + + it("applies provider setting string and serialized byte limits", () => { + expect(() => + createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { metadata: "x".repeat(2_000) }, + }), + ).not.toThrow() + expect(() => + createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { metadata: "x".repeat(2_001) }, + }), + ).toThrow("cannot exceed 2000 characters") + expect(() => + createValidatedProviderRevision({ + providerKey: "workforce", + revision: 1, + settings: { + metadata: Array.from({ length: 9 }, () => "x".repeat(1_900)), + }, + }), + ).toThrow("cannot exceed 16384 bytes") + }) }) diff --git a/hub/server/utils/normalize-secret-free-json/index.test.ts b/hub/server/utils/normalize-secret-free-json/index.test.ts new file mode 100644 index 00000000..50e3aeee --- /dev/null +++ b/hub/server/utils/normalize-secret-free-json/index.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest" + +import type { NormalizeSecretFreeJsonLimits } from "./types" +import { normalizeSecretFreeJson } from "." + +function limits(overrides: Partial = {}) { + return { + maxArrayItems: 10, + maxBytes: 1_000, + maxDepth: 5, + maxEntries: 20, + maxKeyLength: 20, + maxObjectFields: 10, + maxStringLength: 100, + ...overrides, + } +} + +describe("normalizeSecretFreeJson()", () => { + it("accepts the exact UTF-8 byte limit and rejects one byte less", () => { + const input = { label: "界" } + const bytes = Buffer.byteLength(JSON.stringify(input), "utf8") + + expect(normalizeSecretFreeJson(input, limits({ maxBytes: bytes }))).toEqual(input) + expect(() => normalizeSecretFreeJson(input, limits({ maxBytes: bytes - 1 }))).toThrow( + `cannot exceed ${bytes - 1} bytes`, + ) + }) + + it("accepts the exact string length and rejects the next character", () => { + expect(normalizeSecretFreeJson({ label: "abc" }, limits({ maxStringLength: 3 }))).toEqual({ + label: "abc", + }) + expect(() => + normalizeSecretFreeJson({ label: "abcd" }, limits({ maxStringLength: 3 })), + ).toThrow("cannot exceed 3 characters") + }) + + it("accepts the exact key length and rejects the next character", () => { + expect(normalizeSecretFreeJson({ abc: 1 }, limits({ maxKeyLength: 3 }))).toEqual({ abc: 1 }) + expect(() => normalizeSecretFreeJson({ abcd: 1 }, limits({ maxKeyLength: 3 }))).toThrow( + "between 1 and 3 characters", + ) + }) + + it("accepts the exact array length and rejects the next item", () => { + expect(normalizeSecretFreeJson({ values: [1, 2] }, limits({ maxArrayItems: 2 }))).toEqual({ + values: [1, 2], + }) + expect(() => + normalizeSecretFreeJson({ values: [1, 2, 3] }, limits({ maxArrayItems: 2 })), + ).toThrow("cannot exceed 2 items") + }) + + it("accepts the exact object property count and rejects the next property", () => { + expect( + normalizeSecretFreeJson({ first: 1, second: 2 }, limits({ maxObjectFields: 2 })), + ).toEqual({ first: 1, second: 2 }) + expect(() => + normalizeSecretFreeJson({ first: 1, second: 2, third: 3 }, limits({ maxObjectFields: 2 })), + ).toThrow("cannot exceed 2 fields") + }) + + it("accepts the exact total entry count and rejects one fewer", () => { + const input = { values: [true, false] } + + expect(normalizeSecretFreeJson(input, limits({ maxEntries: 3 }))).toEqual(input) + expect(() => normalizeSecretFreeJson(input, limits({ maxEntries: 2 }))).toThrow( + "cannot exceed 2 total entries", + ) + }) + + it("rejects every non-finite number", () => { + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + expect(() => normalizeSecretFreeJson({ value }, limits())).toThrow("must be finite") + } + }) + + it("rejects unsupported object prototypes", () => { + class Configuration { + value = "metadata" + } + + for (const value of [new Configuration(), new Date(0), new Map(), new Set()]) { + expect(() => normalizeSecretFreeJson({ value }, limits())).toThrow( + "only plain objects, arrays, and scalar values", + ) + } + }) + + it("requires an object root while allowing nested scalar and array values", () => { + const nested = { values: [null, true, 1, "text", { enabled: false }] } + + expect(normalizeSecretFreeJson(nested, limits())).toEqual(nested) + for (const root of [null, true, 1, "text", [1, 2]]) { + expect(() => normalizeSecretFreeJson(root, limits())).toThrow("must be an object") + } + }) + + it("accepts the exact recursion depth and rejects the next level", () => { + expect( + normalizeSecretFreeJson({ nested: { value: "at-limit" } }, limits({ maxDepth: 2 })), + ).toEqual({ nested: { value: "at-limit" } }) + expect(() => + normalizeSecretFreeJson( + { nested: { deeper: { value: "too-deep" } } }, + limits({ maxDepth: 2 }), + ), + ).toThrow("cannot exceed 2 nested levels") + }) +}) From c6331f049c73ca7c626a2c1f2365c22abdf8d373 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 15:12:41 -0700 Subject: [PATCH 4/8] fix(hub): secure identity transaction boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/architecture.md | 4 +- hub/docs/backend-architecture.md | 4 +- hub/docs/database.md | 2 +- .../authentication-provider-revision.prisma | 1 - .../migration.sql | 19 +- .../index.test.ts | 116 ------ .../index.ts | 49 --- .../types.ts | 11 - hub/server/domains/identity/errors.ts | 2 +- .../parse-external-identity-tuple/index.ts | 22 ++ .../index.db.test.ts | 219 ------------ .../index.ts | 117 ------ .../types.ts | 15 - .../resolve-external-identity/index.ts | 14 +- .../index.db.test.ts | 91 +++-- .../index.ts | 76 ++-- .../types.ts | 9 +- .../index.ts | 2 +- .../types.ts | 4 +- .../index.ts | 2 +- .../types.ts | 5 +- .../run-with-bounded-retries/index.test.ts | 61 ++++ .../run-with-bounded-retries/index.ts | 34 ++ .../run-with-bounded-retries/types.ts | 10 + .../index.db.test.ts | 336 ++++++++++++++++++ .../index.ts | 147 ++++++++ .../types.ts | 25 ++ .../types.ts | 1 - .../create-security-audit/index.test.ts | 178 +++++----- .../create-security-audit/index.ts | 161 +++++++-- .../create-security-audit/types.ts | 59 ++- .../normalize-bounded-json/index.test.ts | 109 ++++++ .../index.ts | 6 +- .../types.ts | 2 +- .../normalize-secret-free-json/index.test.ts | 111 ------ .../identity-foundation/index.db.test.ts | 132 +++++-- 36 files changed, 1239 insertions(+), 917 deletions(-) delete mode 100644 hub/server/domains/identity/create-validated-provider-revision/index.test.ts delete mode 100644 hub/server/domains/identity/create-validated-provider-revision/index.ts delete mode 100644 hub/server/domains/identity/create-validated-provider-revision/types.ts create mode 100644 hub/server/domains/identity/parse-external-identity-tuple/index.ts delete mode 100644 hub/server/domains/identity/persist-validated-provider-revision/index.db.test.ts delete mode 100644 hub/server/domains/identity/persist-validated-provider-revision/index.ts delete mode 100644 hub/server/domains/identity/persist-validated-provider-revision/types.ts create mode 100644 hub/server/domains/identity/run-with-bounded-retries/index.test.ts create mode 100644 hub/server/domains/identity/run-with-bounded-retries/index.ts create mode 100644 hub/server/domains/identity/run-with-bounded-retries/types.ts create mode 100644 hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts create mode 100644 hub/server/domains/identity/validate-and-persist-provider-revision/index.ts create mode 100644 hub/server/domains/identity/validate-and-persist-provider-revision/types.ts create mode 100644 hub/server/utils/normalize-bounded-json/index.test.ts rename hub/server/utils/{normalize-secret-free-json => normalize-bounded-json}/index.ts (97%) rename hub/server/utils/{normalize-secret-free-json => normalize-bounded-json}/types.ts (89%) delete mode 100644 hub/server/utils/normalize-secret-free-json/index.test.ts diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 58ec3c22..190931b9 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -26,7 +26,9 @@ The [telemetry integration](telemetry.md) provides explicit server-side OpenTele The [WebSocket foundation](websockets.md) attaches to the API's HTTP server and broadcasts to local channel subscribers without persistence. The application rejects upgrades until access checks are supplied. Product channels, client recovery, and communication between processes are not implemented. -The identity foundation stores Hub users, immutable external identity mappings, selected authentication provider revisions, and bounded security audits. Domain operations receive only the transaction-scoped repositories they need. A successful state change and its success audit commit together; a rejected transaction rolls back before its denial or failure audit is written. Provider and other external I/O must finish before a provider revision enters the database transaction. +The identity foundation stores Hub users, immutable external identity mappings, selected authentication provider revision digests, and bounded security audits. It does not store provider configuration. A provider adapter must validate its typed public configuration before the kernel computes a digest and starts the database transaction. + +Domain operations receive only the transaction-scoped repositories they need. A successful state change and its success audit commit together. Expected serializable and aggregate-creation races retry the complete database operation against a fresh snapshot. A rejected operation rolls back before its final denial or failure audit is written. Audit callers select a typed kernel event; the repository builds the stored action and allowlisted context. This foundation does not prove a provider assertion, create a usable Hub session, assign membership, or authorize product access. diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 3bb0f0a0..8f8fd557 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -45,4 +45,6 @@ The [shared Prisma client](database.md#shared-client) lives in `server/integrati The identity kernel keeps database access in focused repositories for users, external identities, authentication provider revisions, and security audits. Its domain operations accept transaction-scoped repository functions rather than a mutable store or a global lock. -Keep network, provider, and filesystem work outside the transaction. A transaction may change one identity or authentication-provider aggregate and write its success audit. When work is denied or fails, roll back that transaction before writing the separate denial or failure audit. +Keep network, provider, and filesystem work outside the transaction. Provider validation returns canonical public configuration, and the kernel computes and stores only its digest. A transaction may change one identity or authentication-provider aggregate and write its success audit. + +Retry expected serialization and aggregate-creation conflicts around the complete transaction so the next attempt rereads current state. Bound attempts and backoff. Do not retry invariant or foreign-key failures. Write a denial or failure audit only after the state transaction has rolled back and the operation has reached its terminal result. diff --git a/hub/docs/database.md b/hub/docs/database.md index 4f5a332b..04405744 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. The schema contains the provider-neutral identity transaction foundation. Hub sessions, memberships, invitations, source integrations, and product records are not part of this foundation. +Hub uses PostgreSQL and Prisma. The schema contains the provider-neutral identity transaction foundation. Authentication provider revisions store a validated-configuration digest, not provider configuration, and every committed provider must select one of its own revisions. Hub sessions, memberships, invitations, source integrations, and product records are not part of this foundation. [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. diff --git a/hub/schema/authentication-provider-revision.prisma b/hub/schema/authentication-provider-revision.prisma index fd501c32..7095b0c4 100644 --- a/hub/schema/authentication-provider-revision.prisma +++ b/hub/schema/authentication-provider-revision.prisma @@ -10,7 +10,6 @@ model AuthenticationProviderRevision { // Attribute columns revision Int configurationDigest String @map("configuration_digest") @db.VarChar(64) - settings Json @db.JsonB // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) diff --git a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql index e77db287..e69f9ed6 100644 --- a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql +++ b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql @@ -39,7 +39,6 @@ CREATE TABLE "authentication_provider_revisions" ( "provider_id" UUID NOT NULL, "revision" INTEGER NOT NULL, "configuration_digest" VARCHAR(64) NOT NULL, - "settings" JSONB NOT NULL, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -47,9 +46,6 @@ CREATE TABLE "authentication_provider_revisions" ( CONSTRAINT "authentication_provider_revisions_revision_positive" CHECK ("revision" > 0), CONSTRAINT "authentication_provider_revisions_digest_sha256" CHECK ( "configuration_digest" ~ '^[0-9a-f]{64}$' - ), - CONSTRAINT "authentication_provider_revisions_settings_object" CHECK ( - jsonb_typeof("settings") = 'object' ) ); @@ -148,11 +144,22 @@ CREATE FUNCTION enforce_selected_revision_provider() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + selected_revision UUID; BEGIN - IF NEW."selected_revision_id" IS NOT NULL AND NOT EXISTS ( + SELECT "selected_revision_id" + INTO selected_revision + FROM "authentication_providers" + WHERE "id" = NEW."id"; + + IF selected_revision IS NULL THEN + RAISE EXCEPTION 'Authentication provider must have a selected revision.'; + END IF; + + IF NOT EXISTS ( SELECT 1 FROM "authentication_provider_revisions" - WHERE "id" = NEW."selected_revision_id" + WHERE "id" = selected_revision AND "provider_id" = NEW."id" ) THEN RAISE EXCEPTION 'Selected revision must belong to its authentication provider.'; diff --git a/hub/server/domains/identity/create-validated-provider-revision/index.test.ts b/hub/server/domains/identity/create-validated-provider-revision/index.test.ts deleted file mode 100644 index 562925e5..00000000 --- a/hub/server/domains/identity/create-validated-provider-revision/index.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { createValidatedProviderRevision } from "." - -describe("createValidatedProviderRevision()", () => { - it("creates the same digest for recursively equivalent settings regardless of key order", () => { - const first = createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { - z: 1, - ä: 2, - nested: { issuer: "https://id.example.test", audience: "hub" }, - }, - }) - const second = createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { - nested: { audience: "hub", issuer: "https://id.example.test" }, - ä: 2, - z: 1, - }, - }) - - expect(first.configurationDigest).toMatch(/^[0-9a-f]{64}$/) - expect(second.configurationDigest).toBe(first.configurationDigest) - expect(second.settings).toEqual(first.settings) - expect(Object.keys(first.settings)).toEqual(["nested", "z", "ä"]) - }) - - it("rejects secret-bearing keys and values at every supported nesting level", () => { - expect(() => - createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { privateKey: "must-not-persist" }, - }), - ).toThrow("cannot contain secret-bearing fields") - const secrets: Array> = [ - { databasePassword: "must-not-persist" }, - { clientSecret: "must-not-persist" }, - { accessToken: "must-not-persist" }, - { signingPrivateKey: "must-not-persist" }, - ] - for (const secret of secrets) { - expect(() => - createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { metadata: [secret] }, - }), - ).toThrow("cannot contain secret-bearing fields") - } - expect(() => - createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { - certificate: [ - "-----BEGIN PRIVATE", - " KEY-----\nmaterial\n-----END PRIVATE", - " KEY-----", - ].join(""), - }, - }), - ).toThrow("cannot contain credential or key material") - }) - - it("allows non-secret key metadata and enforces recursion bounds", () => { - expect(() => - createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { - tokenEndpoint: "https://id.example.test/token", - keyId: "signing-key-1", - publicKeyUrl: "https://id.example.test/keys", - }, - }), - ).not.toThrow() - expect(() => - createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { a: { b: { c: { d: { e: "too-deep" } } } } }, - }), - ).toThrow("cannot exceed 4 nested levels") - }) - - it("applies provider setting string and serialized byte limits", () => { - expect(() => - createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { metadata: "x".repeat(2_000) }, - }), - ).not.toThrow() - expect(() => - createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { metadata: "x".repeat(2_001) }, - }), - ).toThrow("cannot exceed 2000 characters") - expect(() => - createValidatedProviderRevision({ - providerKey: "workforce", - revision: 1, - settings: { - metadata: Array.from({ length: 9 }, () => "x".repeat(1_900)), - }, - }), - ).toThrow("cannot exceed 16384 bytes") - }) -}) diff --git a/hub/server/domains/identity/create-validated-provider-revision/index.ts b/hub/server/domains/identity/create-validated-provider-revision/index.ts deleted file mode 100644 index 9d4cabd7..00000000 --- a/hub/server/domains/identity/create-validated-provider-revision/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createHash } from "node:crypto" -import { z } from "zod" - -import { normalizeSecretFreeJson } from "../../../utils/normalize-secret-free-json/index.ts" -import type { CreateValidatedProviderRevisionInput, ValidatedProviderRevision } from "./types.ts" - -const providerKeySchema = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/) - -export function createValidatedProviderRevision( - input: CreateValidatedProviderRevisionInput, -): ValidatedProviderRevision { - const providerKey = providerKeySchema.parse(input.providerKey) - if (!Number.isSafeInteger(input.revision) || input.revision < 1) { - throw new Error("Provider revision must be a positive safe integer.") - } - let settings - try { - settings = normalizeSecretFreeJson(input.settings, { - maxArrayItems: 32, - maxBytes: 16_384, - maxDepth: 4, - maxEntries: 128, - maxKeyLength: 100, - maxObjectFields: 32, - maxStringLength: 2_000, - }) - } catch (error) { - if (!(error instanceof Error)) throw error - throw new Error( - error.message - .replace("JSON data", "Provider settings") - .replace("JSON arrays", "Provider setting arrays") - .replace("JSON objects", "Provider setting objects") - .replace("JSON keys", "Provider setting keys") - .replace("JSON string values", "Provider setting values") - .replace("JSON numbers", "Provider setting numbers") - .replace("JSON values", "Provider setting values"), - { cause: error }, - ) - } - const serialized = JSON.stringify(settings) - - return { - providerKey, - revision: input.revision, - settings, - configurationDigest: createHash("sha256").update(serialized).digest("hex"), - } -} diff --git a/hub/server/domains/identity/create-validated-provider-revision/types.ts b/hub/server/domains/identity/create-validated-provider-revision/types.ts deleted file mode 100644 index 6b57b6d4..00000000 --- a/hub/server/domains/identity/create-validated-provider-revision/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Prisma } from "../../../generated/prisma/client.ts" - -export interface CreateValidatedProviderRevisionInput { - providerKey: string - revision: number - settings: Prisma.InputJsonObject -} - -export interface ValidatedProviderRevision extends CreateValidatedProviderRevisionInput { - configurationDigest: string -} diff --git a/hub/server/domains/identity/errors.ts b/hub/server/domains/identity/errors.ts index cc960bb5..f33a806a 100644 --- a/hub/server/domains/identity/errors.ts +++ b/hub/server/domains/identity/errors.ts @@ -38,7 +38,7 @@ export class ProviderRevisionRollbackError extends Error { export class ProviderRevisionDriftError extends Error { constructor() { - super("The provider revision reuses a revision number with different settings.") + super("The provider revision reuses a revision number with different validated configuration.") this.name = "ProviderRevisionDriftError" } } diff --git a/hub/server/domains/identity/parse-external-identity-tuple/index.ts b/hub/server/domains/identity/parse-external-identity-tuple/index.ts new file mode 100644 index 00000000..557d35c2 --- /dev/null +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.ts @@ -0,0 +1,22 @@ +import { z } from "zod" + +import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" + +const externalIdentityTupleSchema = z + .object({ + provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + issuer: z.string().min(1).max(500), + subject: z.string().min(1).max(500), + }) + .strict() + +export function parseExternalIdentityTuple(input: unknown): ExternalIdentityTuple { + if ( + typeof input !== "object" || + input === null || + Object.getPrototypeOf(input) !== Object.prototype + ) { + throw new Error("External identity input must be a plain object.") + } + return externalIdentityTupleSchema.parse(input) +} diff --git a/hub/server/domains/identity/persist-validated-provider-revision/index.db.test.ts b/hub/server/domains/identity/persist-validated-provider-revision/index.db.test.ts deleted file mode 100644 index c3898122..00000000 --- a/hub/server/domains/identity/persist-validated-provider-revision/index.db.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { randomUUID } from "node:crypto" -import { describe, expect, it } from "vitest" - -import { Prisma } from "../../../generated/prisma/client" -import { prisma } from "../../../integrations/prisma" -import { createAuthenticationProviderRevision } from "../../../repositories/authentication-providers/create-authentication-provider-revision" -import { createAuthenticationProvider } from "../../../repositories/authentication-providers/create-authentication-provider" -import { findAuthenticationProvider } from "../../../repositories/authentication-providers/find-authentication-provider" -import { selectAuthenticationProviderRevision } from "../../../repositories/authentication-providers/select-authentication-provider-revision" -import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit" -import { createValidatedProviderRevision } from "../create-validated-provider-revision" -import { - IdentityTransactionConflictError, - ProviderRevisionConflictError, - ProviderRevisionDriftError, - ProviderRevisionGapError, - ProviderRevisionRollbackError, -} from "../errors" -import { runProviderRevisionTransaction } from "../run-provider-revision-transaction" -import type { - ProviderRevisionRepositories, - ProviderRevisionTransaction, -} from "../run-provider-revision-transaction/types" -import { persistValidatedProviderRevision } from "." - -function dependencies() { - return { - createId: randomUUID, - runTransaction: ( - operation: (repositories: ProviderRevisionRepositories) => Promise, - ) => runProviderRevisionTransaction(prisma, operation), - writeAudit: (input: Parameters[1]) => - createSecurityAudit(prisma, input), - } -} - -function revision(revisionNumber: number, audience = `hub-${revisionNumber}`) { - return createValidatedProviderRevision({ - providerKey: "workforce", - revision: revisionNumber, - settings: { issuer: "https://id.example.test", audience }, - }) -} - -function controlledConcurrentTransaction(): ProviderRevisionTransaction { - let arrivals = 0 - let release: (() => void) | undefined - const overlap = new Promise((resolve) => { - release = resolve - }) - - return async (operation) => { - try { - return await prisma.$transaction( - (transaction) => - operation({ - createAuthenticationProvider: (input) => - createAuthenticationProvider(transaction, input), - createAuthenticationProviderRevision: (input) => - createAuthenticationProviderRevision(transaction, input), - createSecurityAudit: (input) => createSecurityAudit(transaction, input), - findAuthenticationProvider: async (key) => { - const provider = await findAuthenticationProvider(transaction, key) - arrivals += 1 - if (arrivals === 2) release?.() - await overlap - return provider - }, - selectAuthenticationProviderRevision: (input) => - selectAuthenticationProviderRevision(transaction, input), - }), - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, - ) - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - ["P2002", "P2003", "P2034"].includes(error.code) - ) { - throw new IdentityTransactionConflictError({ cause: error }) - } - throw error - } - } -} - -describe("persistValidatedProviderRevision()", () => { - it("selects validated revisions atomically with a success audit", async () => { - const first = await persistValidatedProviderRevision(revision(1), dependencies()) - const second = await persistValidatedProviderRevision(revision(2), dependencies()) - - expect(first).toMatchObject({ status: "selected", revision: 1 }) - expect(second).toMatchObject({ - status: "selected", - providerId: first.providerId, - revision: 2, - }) - await expect( - prisma.authenticationProvider.findUnique({ - where: { id: first.providerId }, - include: { selectedRevision: true, revisions: { orderBy: { revision: "asc" } } }, - }), - ).resolves.toMatchObject({ - selectedRevision: { id: second.revisionId, revision: 2 }, - revisions: [{ revision: 1 }, { revision: 2 }], - }) - await expect(prisma.securityAudit.count()).resolves.toBe(2) - }) - - it("returns an unchanged selected revision only for the same digest", async () => { - const first = await persistValidatedProviderRevision(revision(1), dependencies()) - const unchanged = await persistValidatedProviderRevision(revision(1), dependencies()) - - expect(unchanged).toEqual({ ...first, status: "unchanged" }) - await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1) - await expect(prisma.securityAudit.count()).resolves.toBe(1) - }) - - it("rejects rollback, same-revision drift, and revision gaps after rollback", async () => { - await persistValidatedProviderRevision(revision(1), dependencies()) - await persistValidatedProviderRevision(revision(2), dependencies()) - - await expect( - persistValidatedProviderRevision(revision(1), dependencies()), - ).rejects.toBeInstanceOf(ProviderRevisionRollbackError) - await expect( - persistValidatedProviderRevision(revision(2, "changed"), dependencies()), - ).rejects.toBeInstanceOf(ProviderRevisionDriftError) - await expect( - persistValidatedProviderRevision(revision(4), dependencies()), - ).rejects.toBeInstanceOf(ProviderRevisionGapError) - await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) - const deniedAudits = await prisma.securityAudit.findMany({ - where: { outcome: "DENIED" }, - orderBy: { createdAt: "asc" }, - }) - expect(deniedAudits.map(({ context }) => context)).toEqual([ - { provider: "workforce", revision: 1, reason: "rollback" }, - { provider: "workforce", revision: 2, reason: "same_revision_drift" }, - { provider: "workforce", revision: 4, reason: "revision_gap" }, - ]) - }) - - it("rolls back a partially written revision before recording its failure audit", async () => { - await persistValidatedProviderRevision(revision(1), dependencies()) - const base = dependencies() - async function runTransaction( - operation: (repositories: ProviderRevisionRepositories) => Promise, - ): Promise { - return prisma.$transaction( - (transaction) => - operation({ - createAuthenticationProvider: (input) => - createAuthenticationProvider(transaction, input), - createAuthenticationProviderRevision: (input) => - createAuthenticationProviderRevision(transaction, input), - createSecurityAudit: (input) => createSecurityAudit(transaction, input), - findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), - selectAuthenticationProviderRevision: () => - Promise.reject(new Error("injected selection failure")), - }), - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, - ) - } - - await expect( - persistValidatedProviderRevision(revision(2), { ...base, runTransaction }), - ).rejects.toThrow("injected selection failure") - await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1) - await expect( - prisma.authenticationProvider.findUnique({ - where: { key: "workforce" }, - include: { selectedRevision: true }, - }), - ).resolves.toMatchObject({ selectedRevision: { revision: 1 } }) - const failureAudit = await prisma.securityAudit.findFirst({ - where: { outcome: "FAILURE" }, - }) - expect(failureAudit?.action).toBe("identity.provider_revision.persist_failed") - expect(failureAudit?.context).toEqual({ - provider: "workforce", - revision: 2, - reason: "transaction_error", - }) - }) - - it("overlaps competing next revisions and commits exactly one complete update", async () => { - await persistValidatedProviderRevision(revision(1), dependencies()) - const runTransaction = controlledConcurrentTransaction() - const first = persistValidatedProviderRevision(revision(2, "first"), { - ...dependencies(), - runTransaction, - }) - const second = persistValidatedProviderRevision(revision(2, "second"), { - ...dependencies(), - runTransaction, - }) - - const results = await Promise.allSettled([first, second]) - - expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) - expect( - results.filter( - (result) => - result.status === "rejected" && result.reason instanceof ProviderRevisionConflictError, - ), - ).toHaveLength(1) - await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) - const audits = await prisma.securityAudit.groupBy({ - by: ["outcome"], - _count: { _all: true }, - }) - expect(Object.fromEntries(audits.map(({ outcome, _count }) => [outcome, _count._all]))).toEqual( - { - SUCCESS: 2, - FAILURE: 1, - }, - ) - }) -}) diff --git a/hub/server/domains/identity/persist-validated-provider-revision/index.ts b/hub/server/domains/identity/persist-validated-provider-revision/index.ts deleted file mode 100644 index 52843215..00000000 --- a/hub/server/domains/identity/persist-validated-provider-revision/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" -import { - IdentityTransactionConflictError, - ProviderRevisionConflictError, - ProviderRevisionDriftError, - ProviderRevisionGapError, - ProviderRevisionInvariantError, - ProviderRevisionRollbackError, -} from "../errors.ts" -import { getSelectedProviderRevision } from "../get-selected-provider-revision/index.ts" -import type { - PersistValidatedProviderRevisionDependencies, - PersistValidatedProviderRevisionInput, - PersistValidatedProviderRevisionResult, -} from "./types.ts" - -function denialReason(error: unknown) { - if (error instanceof ProviderRevisionRollbackError) return "rollback" - if (error instanceof ProviderRevisionDriftError) return "same_revision_drift" - if (error instanceof ProviderRevisionGapError) return "revision_gap" - if (error instanceof ProviderRevisionInvariantError) return "invariant" - return null -} - -export async function persistValidatedProviderRevision( - input: PersistValidatedProviderRevisionInput, - dependencies: PersistValidatedProviderRevisionDependencies, -): Promise { - const providerId = dependencies.createId() - const revisionId = dependencies.createId() - const successAuditId = dependencies.createId() - - try { - return await dependencies.runTransaction(async (repositories) => { - const current = await getSelectedProviderRevision(input.providerKey, repositories) - let selectedProviderId = providerId - - if (current) { - selectedProviderId = current.providerId - if (input.revision < current.revision) throw new ProviderRevisionRollbackError() - if (input.revision === current.revision) { - if (input.configurationDigest !== current.configurationDigest) { - throw new ProviderRevisionDriftError() - } - return { - status: "unchanged", - providerId: current.providerId, - revisionId: current.id, - revision: current.revision, - } - } - if (input.revision !== current.revision + 1) throw new ProviderRevisionGapError() - } else { - if (input.revision !== 1) throw new ProviderRevisionGapError() - await repositories.createAuthenticationProvider({ - id: providerId, - key: input.providerKey, - }) - } - - await repositories.createAuthenticationProviderRevision({ - id: revisionId, - providerId: selectedProviderId, - revision: input.revision, - configurationDigest: input.configurationDigest, - settings: input.settings, - }) - await repositories.selectAuthenticationProviderRevision({ - providerId: selectedProviderId, - revisionId, - }) - await repositories.createSecurityAudit({ - id: successAuditId, - action: "identity.provider_revision.selected", - outcome: SecurityAuditOutcome.SUCCESS, - targetType: "authentication_provider", - targetId: selectedProviderId, - context: { provider: input.providerKey, revision: input.revision }, - }) - - return { - status: "selected", - providerId: selectedProviderId, - revisionId, - revision: input.revision, - } - }) - } catch (error) { - const reason = denialReason(error) - const conflict = error instanceof IdentityTransactionConflictError - const auditError = conflict ? new ProviderRevisionConflictError({ cause: error }) : error - try { - await dependencies.writeAudit({ - id: dependencies.createId(), - action: conflict - ? "identity.provider_revision.persist_failed" - : reason - ? "identity.provider_revision.denied" - : "identity.provider_revision.persist_failed", - outcome: conflict || !reason ? SecurityAuditOutcome.FAILURE : SecurityAuditOutcome.DENIED, - targetType: "authentication_provider", - context: { - provider: input.providerKey, - revision: input.revision, - reason: reason ?? (conflict ? "collision" : "transaction_error"), - }, - }) - } catch (failureAuditError) { - throw new AggregateError( - [auditError, failureAuditError], - "The provider revision rejection and its audit could not both be recorded.", - { cause: failureAuditError }, - ) - } - throw auditError - } -} diff --git a/hub/server/domains/identity/persist-validated-provider-revision/types.ts b/hub/server/domains/identity/persist-validated-provider-revision/types.ts deleted file mode 100644 index f8414938..00000000 --- a/hub/server/domains/identity/persist-validated-provider-revision/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { CreateSecurityAuditInput } from "../../../repositories/security-audits/create-security-audit/types.ts" -import type { ValidatedProviderRevision } from "../create-validated-provider-revision/types.ts" -import type { ProviderRevisionTransaction } from "../run-provider-revision-transaction/types.ts" - -export interface PersistValidatedProviderRevisionDependencies { - createId(): string - runTransaction: ProviderRevisionTransaction - writeAudit(input: CreateSecurityAuditInput): Promise -} - -export type PersistValidatedProviderRevisionResult = - | { status: "selected"; providerId: string; revisionId: string; revision: number } - | { status: "unchanged"; providerId: string; revisionId: string; revision: number } - -export type PersistValidatedProviderRevisionInput = ValidatedProviderRevision diff --git a/hub/server/domains/identity/resolve-external-identity/index.ts b/hub/server/domains/identity/resolve-external-identity/index.ts index 4acef9de..0d4fb2f1 100644 --- a/hub/server/domains/identity/resolve-external-identity/index.ts +++ b/hub/server/domains/identity/resolve-external-identity/index.ts @@ -1,19 +1,11 @@ -import { z } from "zod" - -import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" +import { parseExternalIdentityTuple } from "../parse-external-identity-tuple/index.ts" import type { ResolveExternalIdentityDependencies, ResolveExternalIdentityResult } from "./types.ts" -const externalIdentityTupleSchema = z.object({ - provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), - issuer: z.string().min(1).max(500), - subject: z.string().min(1).max(500), -}) - export async function resolveExternalIdentity( - input: ExternalIdentityTuple, + input: unknown, dependencies: ResolveExternalIdentityDependencies, ): Promise { - const tuple = externalIdentityTupleSchema.parse(input) + const tuple = parseExternalIdentityTuple(input) const identity = await dependencies.findExternalIdentity(tuple) return identity diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts index 94df36ae..d9dcac78 100644 --- a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts @@ -24,6 +24,12 @@ const tuple = { function dependencies(createId = randomUUID) { return { createId, + retry: { + baseDelayMs: 0, + maxAttempts: 3, + maxDelayMs: 0, + sleep: () => Promise.resolve(), + }, runTransaction: ( operation: (repositories: IdentityCreationRepositories) => Promise, ) => runIdentityCreationTransaction(prisma, operation), @@ -106,18 +112,41 @@ describe("resolveOrCreateExternalIdentity()", () => { await expect(prisma.securityAudit.count()).resolves.toBe(1) }) - it("rolls back a user collision before writing a bounded failure audit", async () => { + it("retries generated user ID collisions before creating the identity", async () => { const collidedUserId = randomUUID() await createUser(prisma, { id: collidedUserId }) - const ids = [collidedUserId, randomUUID(), randomUUID(), randomUUID()][Symbol.iterator]() + const ids = [ + collidedUserId, + randomUUID(), + randomUUID(), + randomUUID(), + randomUUID(), + randomUUID(), + ][Symbol.iterator]() + + const result = await resolveOrCreateExternalIdentity( + tuple, + dependencies(() => ids.next().value!), + ) + + expect(result.status).toBe("created") + expect(result.userId).not.toBe(collidedUserId) + await expect(prisma.user.count()).resolves.toBe(2) + await expect(prisma.externalIdentity.count()).resolves.toBe(1) + await expect(prisma.securityAudit.count({ where: { outcome: "FAILURE" } })).resolves.toBe(0) + }) + + it("writes one failure audit only after the retry budget is exhausted", async () => { + const collidedUserId = randomUUID() + await createUser(prisma, { id: collidedUserId }) + const configured = dependencies(() => collidedUserId) await expect( - resolveOrCreateExternalIdentity( - tuple, - dependencies(() => ids.next().value!), - ), + resolveOrCreateExternalIdentity(tuple, { + ...configured, + retry: { ...configured.retry, maxAttempts: 2 }, + }), ).rejects.toBeInstanceOf(ExternalIdentityCollisionError) - await expect(prisma.user.count()).resolves.toBe(1) await expect(prisma.externalIdentity.count()).resolves.toBe(0) await expect(prisma.securityAudit.findMany()).resolves.toEqual([ expect.objectContaining({ @@ -126,12 +155,37 @@ describe("resolveOrCreateExternalIdentity()", () => { context: { provider: tuple.provider, issuer: tuple.issuer, - reason: "collision", + reason: "retry_exhausted", }, }), ]) }) + it("rejects excess and non-plain input before attaching an identity to a victim", async () => { + const victimUserId = randomUUID() + await createUser(prisma, { id: victimUserId }) + class IdentityInput { + provider = tuple.provider + issuer = tuple.issuer + subject = tuple.subject + } + + for (const input of [ + { ...tuple, id: randomUUID() }, + { ...tuple, userId: victimUserId }, + { ...tuple, provider: { value: tuple.provider } }, + new IdentityInput(), + ]) { + await expect(resolveOrCreateExternalIdentity(input, dependencies())).rejects.toThrow() + } + + await expect(prisma.user.findMany()).resolves.toEqual([ + expect.objectContaining({ id: victimUserId }), + ]) + await expect(prisma.externalIdentity.count()).resolves.toBe(0) + await expect(prisma.securityAudit.count()).resolves.toBe(0) + }) + it("rolls back partial state before auditing an unexpected transaction failure", async () => { const base = dependencies() async function runTransaction( @@ -176,24 +230,13 @@ describe("resolveOrCreateExternalIdentity()", () => { runTransaction, }) - const results = await Promise.allSettled([first, second]) + const results = await Promise.all([first, second]) - expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) - expect( - results.filter( - (result) => - result.status === "rejected" && result.reason instanceof ExternalIdentityCollisionError, - ), - ).toHaveLength(1) + expect(results.map(({ status }) => status).sort()).toEqual(["created", "found"]) + expect(new Set(results.map(({ userId }) => userId)).size).toBe(1) await expect(prisma.user.count()).resolves.toBe(1) await expect(prisma.externalIdentity.count()).resolves.toBe(1) - await expect( - prisma.securityAudit.groupBy({ by: ["outcome"], _count: { _all: true } }), - ).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ outcome: "SUCCESS", _count: { _all: 1 } }), - expect.objectContaining({ outcome: "FAILURE", _count: { _all: 1 } }), - ]), - ) + await expect(prisma.securityAudit.count({ where: { outcome: "SUCCESS" } })).resolves.toBe(1) + await expect(prisma.securityAudit.count({ where: { outcome: "FAILURE" } })).resolves.toBe(0) }) }) diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/index.ts b/hub/server/domains/identity/resolve-or-create-external-identity/index.ts index a13cc14f..58b9d73e 100644 --- a/hub/server/domains/identity/resolve-or-create-external-identity/index.ts +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.ts @@ -1,6 +1,6 @@ -import { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" import { ExternalIdentityCollisionError, IdentityTransactionConflictError } from "../errors.ts" -import { resolveExternalIdentity } from "../resolve-external-identity/index.ts" +import { parseExternalIdentityTuple } from "../parse-external-identity-tuple/index.ts" +import { runWithBoundedRetries } from "../run-with-bounded-retries/index.ts" import type { ResolveOrCreateExternalIdentityDependencies, ResolveOrCreateExternalIdentityInput, @@ -11,30 +11,46 @@ export async function resolveOrCreateExternalIdentity( input: ResolveOrCreateExternalIdentityInput, dependencies: ResolveOrCreateExternalIdentityDependencies, ): Promise { - const userId = dependencies.createId() - const externalIdentityId = dependencies.createId() - const successAuditId = dependencies.createId() + const tuple = parseExternalIdentityTuple(input) try { - return await dependencies.runTransaction(async (repositories) => { - const existing = await resolveExternalIdentity(input, repositories) - if (existing.status === "found") { - return { ...existing, status: "found" } - } + return await runWithBoundedRetries( + () => + dependencies.runTransaction(async (repositories) => { + const existing = await repositories.findExternalIdentity(tuple) + if (existing) { + return { + status: "found", + externalIdentityId: existing.id, + userId: existing.userId, + } + } - await repositories.createUser({ id: userId }) - await repositories.createExternalIdentity({ id: externalIdentityId, userId, ...input }) - await repositories.createSecurityAudit({ - id: successAuditId, - action: "identity.external.created", - outcome: SecurityAuditOutcome.SUCCESS, - targetType: "external_identity", - targetId: externalIdentityId, - context: { provider: input.provider, issuer: input.issuer }, - }) + const userId = dependencies.createId() + const externalIdentityId = dependencies.createId() + await repositories.createUser({ id: userId }) + await repositories.createExternalIdentity({ + id: externalIdentityId, + userId, + provider: tuple.provider, + issuer: tuple.issuer, + subject: tuple.subject, + }) + await repositories.createSecurityAudit({ + id: dependencies.createId(), + type: "external_identity_created", + externalIdentityId, + provider: tuple.provider, + issuer: tuple.issuer, + }) - return { status: "created", externalIdentityId, userId } - }) + return { status: "created", externalIdentityId, userId } + }), + { + ...dependencies.retry, + shouldRetry: (error) => error instanceof IdentityTransactionConflictError, + }, + ) } catch (error) { const failure = error instanceof IdentityTransactionConflictError @@ -43,15 +59,13 @@ export async function resolveOrCreateExternalIdentity( try { await dependencies.writeAudit({ id: dependencies.createId(), - action: "identity.external.create_failed", - outcome: SecurityAuditOutcome.FAILURE, - targetType: "external_identity", - context: { - provider: input.provider, - issuer: input.issuer, - reason: - error instanceof IdentityTransactionConflictError ? "collision" : "transaction_error", - }, + type: "external_identity_create_failed", + provider: tuple.provider, + issuer: tuple.issuer, + reason: + error instanceof IdentityTransactionConflictError + ? "retry_exhausted" + : "transaction_error", }) } catch (auditError) { throw new AggregateError( diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/types.ts b/hub/server/domains/identity/resolve-or-create-external-identity/types.ts index 6e0721b2..1ee7b1e1 100644 --- a/hub/server/domains/identity/resolve-or-create-external-identity/types.ts +++ b/hub/server/domains/identity/resolve-or-create-external-identity/types.ts @@ -1,11 +1,12 @@ -import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" -import type { CreateSecurityAuditInput } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" import type { IdentityCreationTransaction } from "../run-identity-creation-transaction/types.ts" +import type { TransactionRetryOptions } from "../run-with-bounded-retries/types.ts" export interface ResolveOrCreateExternalIdentityDependencies { createId(): string + retry: TransactionRetryOptions runTransaction: IdentityCreationTransaction - writeAudit(input: CreateSecurityAuditInput): Promise + writeAudit(input: CreateSecurityAuditEvent): Promise } export interface ResolveOrCreateExternalIdentityResult { @@ -14,4 +15,4 @@ export interface ResolveOrCreateExternalIdentityResult { userId: string } -export type ResolveOrCreateExternalIdentityInput = ExternalIdentityTuple +export type ResolveOrCreateExternalIdentityInput = unknown diff --git a/hub/server/domains/identity/run-identity-creation-transaction/index.ts b/hub/server/domains/identity/run-identity-creation-transaction/index.ts index abd5cba9..ccc4eacd 100644 --- a/hub/server/domains/identity/run-identity-creation-transaction/index.ts +++ b/hub/server/domains/identity/run-identity-creation-transaction/index.ts @@ -24,7 +24,7 @@ export function runIdentityCreationTransaction( .catch((error: unknown) => { if ( error instanceof Prisma.PrismaClientKnownRequestError && - ["P2002", "P2003", "P2034"].includes(error.code) + ["P2002", "P2034"].includes(error.code) ) { throw new IdentityTransactionConflictError({ cause: error }) } diff --git a/hub/server/domains/identity/run-identity-creation-transaction/types.ts b/hub/server/domains/identity/run-identity-creation-transaction/types.ts index 224efe5d..215fb348 100644 --- a/hub/server/domains/identity/run-identity-creation-transaction/types.ts +++ b/hub/server/domains/identity/run-identity-creation-transaction/types.ts @@ -1,12 +1,12 @@ import type { PrismaClient } from "../../../generated/prisma/client.ts" import type { CreateExternalIdentityInput } from "../../../repositories/external-identities/create-external-identity/types.ts" import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" -import type { CreateSecurityAuditInput } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" import type { CreateUserInput } from "../../../repositories/users/create-user/types.ts" export interface IdentityCreationRepositories { createExternalIdentity(input: CreateExternalIdentityInput): Promise - createSecurityAudit(input: CreateSecurityAuditInput): Promise + createSecurityAudit(input: CreateSecurityAuditEvent): Promise createUser(input: CreateUserInput): Promise findExternalIdentity(tuple: ExternalIdentityTuple): Promise<{ id: string; userId: string } | null> } diff --git a/hub/server/domains/identity/run-provider-revision-transaction/index.ts b/hub/server/domains/identity/run-provider-revision-transaction/index.ts index 01353948..7e55f5b8 100644 --- a/hub/server/domains/identity/run-provider-revision-transaction/index.ts +++ b/hub/server/domains/identity/run-provider-revision-transaction/index.ts @@ -28,7 +28,7 @@ export function runProviderRevisionTransaction( .catch((error: unknown) => { if ( error instanceof Prisma.PrismaClientKnownRequestError && - ["P2002", "P2003", "P2034"].includes(error.code) + ["P2002", "P2034"].includes(error.code) ) { throw new IdentityTransactionConflictError({ cause: error }) } diff --git a/hub/server/domains/identity/run-provider-revision-transaction/types.ts b/hub/server/domains/identity/run-provider-revision-transaction/types.ts index 41655d86..1f34c992 100644 --- a/hub/server/domains/identity/run-provider-revision-transaction/types.ts +++ b/hub/server/domains/identity/run-provider-revision-transaction/types.ts @@ -2,14 +2,13 @@ import type { PrismaClient } from "../../../generated/prisma/client.ts" import type { CreateAuthenticationProviderRevisionInput } from "../../../repositories/authentication-providers/create-authentication-provider-revision/types.ts" import type { CreateAuthenticationProviderInput } from "../../../repositories/authentication-providers/create-authentication-provider/types.ts" import type { SelectAuthenticationProviderRevisionInput } from "../../../repositories/authentication-providers/select-authentication-provider-revision/types.ts" -import type { CreateSecurityAuditInput } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" export interface SelectedProviderRevisionRecord { id: string providerId: string revision: number configurationDigest: string - settings: unknown } export interface AuthenticationProviderRecord { @@ -23,7 +22,7 @@ export interface ProviderRevisionRepositories { createAuthenticationProviderRevision( input: CreateAuthenticationProviderRevisionInput, ): Promise - createSecurityAudit(input: CreateSecurityAuditInput): Promise + createSecurityAudit(input: CreateSecurityAuditEvent): Promise findAuthenticationProvider(key: string): Promise selectAuthenticationProviderRevision( input: SelectAuthenticationProviderRevisionInput, diff --git a/hub/server/domains/identity/run-with-bounded-retries/index.test.ts b/hub/server/domains/identity/run-with-bounded-retries/index.test.ts new file mode 100644 index 00000000..414c9c97 --- /dev/null +++ b/hub/server/domains/identity/run-with-bounded-retries/index.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest" + +import { IdentityTransactionConflictError } from "../errors" +import { runWithBoundedRetries } from "." + +describe("runWithBoundedRetries()", () => { + it("retries retryable failures with bounded exponential delays", async () => { + const operation = vi + .fn<(attempt: number) => Promise>() + .mockRejectedValueOnce(new IdentityTransactionConflictError()) + .mockRejectedValueOnce(new IdentityTransactionConflictError()) + .mockResolvedValue("recovered") + const sleep = vi.fn<(delayMs: number) => Promise>().mockResolvedValue(undefined) + + await expect( + runWithBoundedRetries(operation, { + baseDelayMs: 5, + maxAttempts: 3, + maxDelayMs: 8, + shouldRetry: (error) => error instanceof IdentityTransactionConflictError, + sleep, + }), + ).resolves.toBe("recovered") + expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([1, 2, 3]) + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5, 8]) + }) + + it("stops after the configured attempt budget", async () => { + const failure = new IdentityTransactionConflictError() + const operation = vi.fn(() => Promise.reject(failure)) + + await expect( + runWithBoundedRetries(operation, { + baseDelayMs: 0, + maxAttempts: 2, + maxDelayMs: 0, + shouldRetry: (error) => error instanceof IdentityTransactionConflictError, + sleep: () => Promise.resolve(), + }), + ).rejects.toBe(failure) + expect(operation).toHaveBeenCalledTimes(2) + }) + + it("does not retry non-retryable failures", async () => { + const failure = new Error("non-retryable") + const operation = vi.fn(() => Promise.reject(failure)) + const sleep = vi.fn<(delayMs: number) => Promise>() + + await expect( + runWithBoundedRetries(operation, { + baseDelayMs: 5, + maxAttempts: 3, + maxDelayMs: 10, + shouldRetry: (error) => error instanceof IdentityTransactionConflictError, + sleep, + }), + ).rejects.toBe(failure) + expect(operation).toHaveBeenCalledOnce() + expect(sleep).not.toHaveBeenCalled() + }) +}) diff --git a/hub/server/domains/identity/run-with-bounded-retries/index.ts b/hub/server/domains/identity/run-with-bounded-retries/index.ts new file mode 100644 index 00000000..f983abc7 --- /dev/null +++ b/hub/server/domains/identity/run-with-bounded-retries/index.ts @@ -0,0 +1,34 @@ +import type { RunWithBoundedRetriesInput } from "./types.ts" + +export async function runWithBoundedRetries( + operation: (attempt: number) => Promise, + options: RunWithBoundedRetriesInput, +): Promise { + if ( + !Number.isSafeInteger(options.maxAttempts) || + options.maxAttempts < 1 || + options.maxAttempts > 5 + ) { + throw new Error("Transaction retry attempts must be between 1 and 5.") + } + if ( + !Number.isSafeInteger(options.baseDelayMs) || + options.baseDelayMs < 0 || + !Number.isSafeInteger(options.maxDelayMs) || + options.maxDelayMs < options.baseDelayMs || + options.maxDelayMs > 30_000 + ) { + throw new Error("Transaction retry delays must be bounded between 0 and 30000 milliseconds.") + } + + for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { + try { + return await operation(attempt) + } catch (error) { + if (attempt === options.maxAttempts || !options.shouldRetry(error)) throw error + const delay = Math.min(options.baseDelayMs * 2 ** (attempt - 1), options.maxDelayMs) + await options.sleep(delay) + } + } + throw new Error("Transaction retry loop ended without a result.") +} diff --git a/hub/server/domains/identity/run-with-bounded-retries/types.ts b/hub/server/domains/identity/run-with-bounded-retries/types.ts new file mode 100644 index 00000000..bd4ea836 --- /dev/null +++ b/hub/server/domains/identity/run-with-bounded-retries/types.ts @@ -0,0 +1,10 @@ +export interface TransactionRetryOptions { + baseDelayMs: number + maxAttempts: number + maxDelayMs: number + sleep(delayMs: number): Promise +} + +export interface RunWithBoundedRetriesInput extends TransactionRetryOptions { + shouldRetry(error: unknown): boolean +} diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts new file mode 100644 index 00000000..f934da8d --- /dev/null +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts @@ -0,0 +1,336 @@ +import { createHash, randomUUID } from "node:crypto" +import { describe, expect, it, vi } from "vitest" +import { z } from "zod" + +import { Prisma } from "../../../generated/prisma/client" +import { prisma } from "../../../integrations/prisma" +import { createAuthenticationProviderRevision } from "../../../repositories/authentication-providers/create-authentication-provider-revision" +import { createAuthenticationProvider } from "../../../repositories/authentication-providers/create-authentication-provider" +import { findAuthenticationProvider } from "../../../repositories/authentication-providers/find-authentication-provider" +import { selectAuthenticationProviderRevision } from "../../../repositories/authentication-providers/select-authentication-provider-revision" +import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit" +import { + IdentityTransactionConflictError, + ProviderRevisionDriftError, + ProviderRevisionGapError, + ProviderRevisionRollbackError, +} from "../errors" +import { runProviderRevisionTransaction } from "../run-provider-revision-transaction" +import type { + ProviderRevisionRepositories, + ProviderRevisionTransaction, +} from "../run-provider-revision-transaction/types" +import { validateAndPersistProviderRevision } from "." + +interface ConfigurationCandidate { + audience: string +} + +const candidateSchema = z.object({ audience: z.string().min(1) }).strict() + +function validateConfiguration(candidate: ConfigurationCandidate) { + const parsed = candidateSchema.parse(candidate) + return Promise.resolve({ + canonicalPublicConfiguration: JSON.stringify({ audience: parsed.audience }), + }) +} + +function dependencies( + validator: (candidate: ConfigurationCandidate) => Promise<{ + canonicalPublicConfiguration: string + }> = validateConfiguration, +) { + return { + createId: randomUUID, + retry: { + baseDelayMs: 0, + maxAttempts: 3, + maxDelayMs: 0, + sleep: () => Promise.resolve(), + }, + runTransaction: ( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ) => runProviderRevisionTransaction(prisma, operation), + validateConfiguration: validator, + writeAudit: (input: Parameters[1]) => + createSecurityAudit(prisma, input), + } +} + +function input(revision: number, audience = `hub-${revision}`) { + return { + providerKey: "workforce", + revision, + configuration: { audience }, + } +} + +function controlledConcurrentTransaction(): ProviderRevisionTransaction { + let arrivals = 0 + let release: (() => void) | undefined + const overlap = new Promise((resolve) => { + release = resolve + }) + + return async (operation) => { + try { + return await prisma.$transaction( + (transaction) => + operation({ + createAuthenticationProvider: (value) => + createAuthenticationProvider(transaction, value), + createAuthenticationProviderRevision: (value) => + createAuthenticationProviderRevision(transaction, value), + createSecurityAudit: (value) => createSecurityAudit(transaction, value), + findAuthenticationProvider: async (key) => { + const provider = await findAuthenticationProvider(transaction, key) + if (arrivals < 2) { + arrivals += 1 + if (arrivals === 2) release?.() + await overlap + } + return provider + }, + selectAuthenticationProviderRevision: (value) => + selectAuthenticationProviderRevision(transaction, value), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + ["P2002", "P2034"].includes(error.code) + ) { + throw new IdentityTransactionConflictError({ cause: error }) + } + throw error + } + } +} + +describe("validateAndPersistProviderRevision()", () => { + it("validates before the transaction and persists only the kernel-computed digest", async () => { + const order: string[] = [] + const validator = vi.fn(async (candidate: ConfigurationCandidate) => { + order.push("validate") + return validateConfiguration(candidate) + }) + const base = dependencies(validator) + async function runTransaction( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ) { + order.push("transaction") + return base.runTransaction(operation) + } + + const result = await validateAndPersistProviderRevision(input(1), { + ...base, + runTransaction, + }) + + expect(order).toEqual(["validate", "transaction"]) + expect(validator).toHaveBeenCalledOnce() + const revision = await prisma.authenticationProviderRevision.findUnique({ + where: { id: result.revisionId }, + }) + expect(revision?.configurationDigest).toBe( + createHash("sha256") + .update(JSON.stringify({ audience: "hub-1" })) + .digest("hex"), + ) + expect(Object.keys(revision ?? {})).not.toContain("settings") + }) + + it("persists no validated configuration value beyond its digest", async () => { + const marker = "opaque-public-configuration-marker" + + await validateAndPersistProviderRevision(input(1, marker), dependencies()) + + const records = await prisma.authenticationProviderRevision.findMany() + expect(JSON.stringify(records)).not.toContain(marker) + expect(records[0]?.configurationDigest).toMatch(/^[0-9a-f]{64}$/) + }) + + it("does not enter a transaction or write an audit when validation fails", async () => { + const validator = vi.fn(() => Promise.reject(new Error("provider validation failed"))) + const base = dependencies(validator) + let transactionCalls = 0 + let auditCalls = 0 + async function runTransaction( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ) { + transactionCalls += 1 + return base.runTransaction(operation) + } + async function writeAudit(input: Parameters[1]) { + auditCalls += 1 + return base.writeAudit(input) + } + + await expect( + validateAndPersistProviderRevision(input(1), { + ...base, + runTransaction, + writeAudit, + }), + ).rejects.toThrow("provider validation failed") + expect(transactionCalls).toBe(0) + expect(auditCalls).toBe(0) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(0) + await expect(prisma.securityAudit.count()).resolves.toBe(0) + }) + + it("rejects unknown configuration and caller-supplied digest fields before persistence", async () => { + const base = dependencies() + const configurationWithUnknownField = { + audience: "hub", + opaque: "untrusted-value", + } + const inputWithDigest = { + ...input(1), + configurationDigest: "a".repeat(64), + } + + await expect( + validateAndPersistProviderRevision( + { + ...input(1), + configuration: configurationWithUnknownField, + }, + dependencies((candidate) => validateConfiguration(candidate)), + ), + ).rejects.toThrow() + await expect(validateAndPersistProviderRevision(inputWithDigest, base)).rejects.toThrow() + await expect(prisma.authenticationProvider.count()).resolves.toBe(0) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(0) + await expect(prisma.securityAudit.count()).resolves.toBe(0) + }) + + it("selects monotonic revisions and returns unchanged for the same validated configuration", async () => { + const first = await validateAndPersistProviderRevision(input(1), dependencies()) + const unchanged = await validateAndPersistProviderRevision(input(1), dependencies()) + const second = await validateAndPersistProviderRevision(input(2), dependencies()) + + expect(unchanged).toEqual({ ...first, status: "unchanged" }) + expect(second).toMatchObject({ + status: "selected", + providerId: first.providerId, + revision: 2, + }) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) + await expect(prisma.securityAudit.count({ where: { outcome: "SUCCESS" } })).resolves.toBe(2) + }) + + it("retries the database operation without repeating external validation", async () => { + const validator = vi.fn(validateConfiguration) + const base = dependencies(validator) + let transactionCalls = 0 + async function runTransaction( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ): Promise { + transactionCalls += 1 + if (transactionCalls === 1) throw new IdentityTransactionConflictError() + return base.runTransaction(operation) + } + + await expect( + validateAndPersistProviderRevision(input(1), { ...base, runTransaction }), + ).resolves.toMatchObject({ status: "selected" }) + expect(validator).toHaveBeenCalledOnce() + expect(transactionCalls).toBe(2) + await expect(prisma.securityAudit.count({ where: { outcome: "FAILURE" } })).resolves.toBe(0) + }) + + it("denies rollback, same-revision drift, and revision gaps after rollback", async () => { + await validateAndPersistProviderRevision(input(1), dependencies()) + await validateAndPersistProviderRevision(input(2), dependencies()) + + await expect( + validateAndPersistProviderRevision(input(1), dependencies()), + ).rejects.toBeInstanceOf(ProviderRevisionRollbackError) + await expect( + validateAndPersistProviderRevision(input(2, "changed"), dependencies()), + ).rejects.toBeInstanceOf(ProviderRevisionDriftError) + await expect( + validateAndPersistProviderRevision(input(4), dependencies()), + ).rejects.toBeInstanceOf(ProviderRevisionGapError) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) + await expect(prisma.securityAudit.count({ where: { outcome: "DENIED" } })).resolves.toBe(3) + }) + + it("rolls back partial persistence before recording a terminal failure audit", async () => { + await validateAndPersistProviderRevision(input(1), dependencies()) + const base = dependencies() + async function runTransaction( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ): Promise { + return prisma.$transaction( + (transaction) => + operation({ + createAuthenticationProvider: (value) => + createAuthenticationProvider(transaction, value), + createAuthenticationProviderRevision: (value) => + createAuthenticationProviderRevision(transaction, value), + createSecurityAudit: (value) => createSecurityAudit(transaction, value), + findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), + selectAuthenticationProviderRevision: () => + Promise.reject(new Error("injected selection failure")), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + } + + await expect( + validateAndPersistProviderRevision(input(2), { ...base, runTransaction }), + ).rejects.toThrow("injected selection failure") + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1) + await expect(prisma.securityAudit.count({ where: { outcome: "FAILURE" } })).resolves.toBe(1) + }) + + it("converges competing equal configuration to selected and unchanged", async () => { + await validateAndPersistProviderRevision(input(1), dependencies()) + const runTransaction = controlledConcurrentTransaction() + const first = validateAndPersistProviderRevision(input(2), { + ...dependencies(), + runTransaction, + }) + const second = validateAndPersistProviderRevision(input(2), { + ...dependencies(), + runTransaction, + }) + + const results = await Promise.all([first, second]) + + expect(results.map(({ status }) => status).sort()).toEqual(["selected", "unchanged"]) + expect(new Set(results.map(({ providerId }) => providerId)).size).toBe(1) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) + await expect(prisma.securityAudit.count({ where: { outcome: "SUCCESS" } })).resolves.toBe(2) + await expect(prisma.securityAudit.count({ where: { outcome: "FAILURE" } })).resolves.toBe(0) + }) + + it("converts competing different configuration to deterministic drift after retry", async () => { + await validateAndPersistProviderRevision(input(1), dependencies()) + const runTransaction = controlledConcurrentTransaction() + const first = validateAndPersistProviderRevision(input(2, "first"), { + ...dependencies(), + runTransaction, + }) + const second = validateAndPersistProviderRevision(input(2, "second"), { + ...dependencies(), + runTransaction, + }) + + const results = await Promise.allSettled([first, second]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect( + results.filter( + (result) => + result.status === "rejected" && result.reason instanceof ProviderRevisionDriftError, + ), + ).toHaveLength(1) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) + await expect(prisma.securityAudit.count({ where: { outcome: "DENIED" } })).resolves.toBe(1) + await expect(prisma.securityAudit.count({ where: { outcome: "FAILURE" } })).resolves.toBe(0) + }) +}) diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts new file mode 100644 index 00000000..ba04dc3d --- /dev/null +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto" +import { z } from "zod" + +import { + IdentityTransactionConflictError, + ProviderRevisionConflictError, + ProviderRevisionDriftError, + ProviderRevisionGapError, + ProviderRevisionInvariantError, + ProviderRevisionRollbackError, +} from "../errors.ts" +import { getSelectedProviderRevision } from "../get-selected-provider-revision/index.ts" +import { runWithBoundedRetries } from "../run-with-bounded-retries/index.ts" +import type { + ValidateAndPersistProviderRevisionDependencies, + ValidateAndPersistProviderRevisionInput, + ValidateAndPersistProviderRevisionResult, +} from "./types.ts" + +const providerRevisionSchema = z + .object({ + providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + revision: z.number().int().positive(), + configuration: z.unknown(), + }) + .strict() + +function denialReason(error: unknown) { + if (error instanceof ProviderRevisionRollbackError) return "rollback" as const + if (error instanceof ProviderRevisionDriftError) return "same_revision_drift" as const + if (error instanceof ProviderRevisionGapError) return "revision_gap" as const + if (error instanceof ProviderRevisionInvariantError) return "invariant" as const + return null +} + +export async function validateAndPersistProviderRevision( + input: ValidateAndPersistProviderRevisionInput, + dependencies: ValidateAndPersistProviderRevisionDependencies, +): Promise { + if ( + typeof input !== "object" || + input === null || + Object.getPrototypeOf(input) !== Object.prototype + ) { + throw new Error("Provider revision input must be a plain object.") + } + const parsed = providerRevisionSchema.parse(input) + const validated = await dependencies.validateConfiguration(input.configuration) + if ( + typeof validated.canonicalPublicConfiguration !== "string" || + !validated.canonicalPublicConfiguration.length || + validated.canonicalPublicConfiguration.length > 16_384 + ) { + throw new Error( + "Provider validation must return canonical public configuration between 1 and 16384 characters.", + ) + } + const configurationDigest = createHash("sha256") + .update(validated.canonicalPublicConfiguration) + .digest("hex") + + try { + return await runWithBoundedRetries( + () => + dependencies.runTransaction(async (repositories) => { + const current = await getSelectedProviderRevision(parsed.providerKey, repositories) + let providerId: string + + if (current) { + providerId = current.providerId + if (parsed.revision < current.revision) throw new ProviderRevisionRollbackError() + if (parsed.revision === current.revision) { + if (configurationDigest !== current.configurationDigest) { + throw new ProviderRevisionDriftError() + } + return { + status: "unchanged", + providerId: current.providerId, + revisionId: current.id, + revision: current.revision, + } + } + if (parsed.revision !== current.revision + 1) throw new ProviderRevisionGapError() + } else { + if (parsed.revision !== 1) throw new ProviderRevisionGapError() + providerId = dependencies.createId() + await repositories.createAuthenticationProvider({ + id: providerId, + key: parsed.providerKey, + }) + } + + const revisionId = dependencies.createId() + await repositories.createAuthenticationProviderRevision({ + id: revisionId, + providerId, + revision: parsed.revision, + configurationDigest, + }) + await repositories.selectAuthenticationProviderRevision({ providerId, revisionId }) + await repositories.createSecurityAudit({ + id: dependencies.createId(), + type: "provider_revision_selected", + providerId, + providerKey: parsed.providerKey, + revision: parsed.revision, + }) + + return { status: "selected", providerId, revisionId, revision: parsed.revision } + }), + { + ...dependencies.retry, + shouldRetry: (error) => error instanceof IdentityTransactionConflictError, + }, + ) + } catch (error) { + const reason = denialReason(error) + const conflict = error instanceof IdentityTransactionConflictError + const reportedError = conflict ? new ProviderRevisionConflictError({ cause: error }) : error + try { + await dependencies.writeAudit( + reason + ? { + id: dependencies.createId(), + type: "provider_revision_denied", + providerKey: parsed.providerKey, + revision: parsed.revision, + reason, + } + : { + id: dependencies.createId(), + type: "provider_revision_persist_failed", + providerKey: parsed.providerKey, + revision: parsed.revision, + reason: conflict ? "retry_exhausted" : "transaction_error", + }, + ) + } catch (failureAuditError) { + throw new AggregateError( + [reportedError, failureAuditError], + "The provider revision rejection and its audit could not both be recorded.", + { cause: failureAuditError }, + ) + } + throw reportedError + } +} diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/types.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/types.ts new file mode 100644 index 00000000..8abc0c87 --- /dev/null +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/types.ts @@ -0,0 +1,25 @@ +import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { ProviderRevisionTransaction } from "../run-provider-revision-transaction/types.ts" +import type { TransactionRetryOptions } from "../run-with-bounded-retries/types.ts" + +export interface ValidatedProviderConfiguration { + canonicalPublicConfiguration: string +} + +export interface ValidateAndPersistProviderRevisionDependencies { + createId(): string + retry: TransactionRetryOptions + runTransaction: ProviderRevisionTransaction + validateConfiguration(candidate: Candidate): Promise + writeAudit(input: CreateSecurityAuditEvent): Promise +} + +export interface ValidateAndPersistProviderRevisionInput { + configuration: Candidate + providerKey: string + revision: number +} + +export type ValidateAndPersistProviderRevisionResult = + | { status: "selected"; providerId: string; revisionId: string; revision: number } + | { status: "unchanged"; providerId: string; revisionId: string; revision: number } diff --git a/hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts b/hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts index b214d252..c6296250 100644 --- a/hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts +++ b/hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts @@ -10,5 +10,4 @@ export interface CreateAuthenticationProviderRevisionInput { providerId: string revision: number configurationDigest: string - settings: Prisma.InputJsonObject } diff --git a/hub/server/repositories/security-audits/create-security-audit/index.test.ts b/hub/server/repositories/security-audits/create-security-audit/index.test.ts index 85b6437f..48b4d1f4 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.test.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.test.ts @@ -1,112 +1,100 @@ import { describe, expect, it, vi } from "vitest" import type { Prisma } from "../../../generated/prisma/client" -import { SecurityAuditOutcome } from "../../../generated/prisma/enums" import { createSecurityAudit } from "." describe("createSecurityAudit()", () => { - it("passes a bounded context to the database", async () => { + it("maps each kernel event to its allowlisted audit record", async () => { const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() create.mockResolvedValue(undefined) + const database = { securityAudit: { create } } + const id = "5dd31031-6f1c-4c16-87c2-aee5342145cc" - const input = { - id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", - action: "identity.external.created", - outcome: SecurityAuditOutcome.SUCCESS, - targetType: "external_identity", - context: { provider: "workforce", revision: 1 }, - } - await createSecurityAudit({ securityAudit: { create } }, input) - - expect(create).toHaveBeenCalledWith({ data: input }) - }) - - it("rejects secret-bearing keys and values recursively before writing", () => { - const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() + await createSecurityAudit(database, { + id, + type: "external_identity_created", + externalIdentityId: "e8714e71-52a7-45e8-a639-99ec37f2d85f", + provider: "workforce", + issuer: "https://id.example.test", + }) + await createSecurityAudit(database, { + id, + type: "external_identity_create_failed", + provider: "workforce", + issuer: "https://id.example.test", + reason: "retry_exhausted", + }) + await createSecurityAudit(database, { + id, + type: "provider_revision_selected", + providerId: "df75faa4-ed4c-44ed-8db0-443cc3b426ed", + providerKey: "workforce", + revision: 1, + }) + await createSecurityAudit(database, { + id, + type: "provider_revision_denied", + providerKey: "workforce", + revision: 1, + reason: "same_revision_drift", + }) + await createSecurityAudit(database, { + id, + type: "provider_revision_persist_failed", + providerKey: "workforce", + revision: 1, + reason: "transaction_error", + }) - expect(() => - createSecurityAudit( - { securityAudit: { create } }, - { - id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", - action: "identity.external.create_failed", - outcome: SecurityAuditOutcome.FAILURE, - targetType: "external_identity", - context: { privateKey: "must-not-persist" }, - }, - ), - ).toThrow("cannot contain secret-bearing fields") - const secrets: Array> = [ - { databasePassword: "must-not-persist" }, - { clientSecret: "must-not-persist" }, - { accessToken: "must-not-persist" }, - { signingPrivateKey: "must-not-persist" }, - ] - for (const secret of secrets) { - expect(() => - createSecurityAudit( - { securityAudit: { create } }, - { - id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", - action: "identity.external.create_failed", - outcome: SecurityAuditOutcome.FAILURE, - targetType: "external_identity", - context: { metadata: [secret] }, - }, - ), - ).toThrow("cannot contain secret-bearing fields") - } - expect(() => - createSecurityAudit( - { securityAudit: { create } }, - { - id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", - action: "identity.external.create_failed", - outcome: SecurityAuditOutcome.FAILURE, - targetType: "external_identity", - context: { - certificate: [ - "-----BEGIN PRIVATE", - " KEY-----\nmaterial\n-----END PRIVATE", - " KEY-----", - ].join(""), - }, + expect(create.mock.calls.map(([{ data }]) => data)).toEqual([ + expect.objectContaining({ + action: "identity.external.created", + outcome: "SUCCESS", + targetType: "external_identity", + context: { issuer: "https://id.example.test", provider: "workforce" }, + }), + expect.objectContaining({ + action: "identity.external.create_failed", + outcome: "FAILURE", + context: { + issuer: "https://id.example.test", + provider: "workforce", + reason: "retry_exhausted", }, - ), - ).toThrow("cannot contain credential or key material") - expect(create).not.toHaveBeenCalled() + }), + expect.objectContaining({ + action: "identity.provider_revision.selected", + outcome: "SUCCESS", + targetType: "authentication_provider", + context: { provider: "workforce", revision: 1 }, + }), + expect.objectContaining({ + action: "identity.provider_revision.denied", + outcome: "DENIED", + context: { provider: "workforce", reason: "same_revision_drift", revision: 1 }, + }), + expect.objectContaining({ + action: "identity.provider_revision.persist_failed", + outcome: "FAILURE", + context: { provider: "workforce", reason: "transaction_error", revision: 1 }, + }), + ]) }) - it("allows non-secret key metadata and rejects context beyond the recursion bound", async () => { + it("rejects unknown fields and caller-supplied context before writing", () => { const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() - create.mockResolvedValue(undefined) + const base = { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + type: "provider_revision_selected" as const, + providerId: "df75faa4-ed4c-44ed-8db0-443cc3b426ed", + providerKey: "workforce", + revision: 1, + } + const withContext = { ...base, context: {} } + const withUnexpected = { ...base, unexpected: { opaque: "untrusted-value" } } - await createSecurityAudit( - { securityAudit: { create } }, - { - id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", - action: "identity.provider_revision.selected", - outcome: SecurityAuditOutcome.SUCCESS, - targetType: "authentication_provider", - context: { - tokenEndpoint: "https://id.example.test/token", - keyId: "signing-key-1", - publicKeyUrl: "https://id.example.test/keys", - }, - }, - ) - expect(() => - createSecurityAudit( - { securityAudit: { create } }, - { - id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", - action: "identity.external.create_failed", - outcome: SecurityAuditOutcome.FAILURE, - targetType: "external_identity", - context: { a: { b: { c: { d: { e: "too-deep" } } } } }, - }, - ), - ).toThrow("cannot exceed 4 nested levels") - expect(create).toHaveBeenCalledTimes(1) + expect(() => createSecurityAudit({ securityAudit: { create } }, withContext)).toThrow() + expect(() => createSecurityAudit({ securityAudit: { create } }, withUnexpected)).toThrow() + expect(create).not.toHaveBeenCalled() }) }) diff --git a/hub/server/repositories/security-audits/create-security-audit/index.ts b/hub/server/repositories/security-audits/create-security-audit/index.ts index 8a328b45..e923e523 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.ts @@ -1,48 +1,133 @@ -import type { CreateSecurityAuditDatabase, CreateSecurityAuditInput } from "./types.ts" +import type { Prisma } from "../../../generated/prisma/client.ts" +import { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" +import { normalizeBoundedJson } from "../../../utils/normalize-bounded-json/index.ts" +import { z } from "zod" +import type { CreateSecurityAuditDatabase, CreateSecurityAuditEvent } from "./types.ts" -import { normalizeSecretFreeJson } from "../../../utils/normalize-secret-free-json/index.ts" +const eventSchema = z.discriminatedUnion("type", [ + z + .object({ + id: z.uuid(), + type: z.literal("external_identity_created"), + externalIdentityId: z.uuid(), + issuer: z.string().min(1).max(500), + provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("external_identity_create_failed"), + issuer: z.string().min(1).max(500), + provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + reason: z.enum(["retry_exhausted", "transaction_error"]), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("provider_revision_selected"), + providerId: z.uuid(), + providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + revision: z.number().int().positive(), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("provider_revision_denied"), + providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + reason: z.enum(["invariant", "revision_gap", "rollback", "same_revision_drift"]), + revision: z.number().int().positive(), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("provider_revision_persist_failed"), + providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + reason: z.enum(["retry_exhausted", "transaction_error"]), + revision: z.number().int().positive(), + }) + .strict(), +]) -function assertBoundedText(name: string, value: string, maximum: number) { - if (!value.length || value.length > maximum) { - throw new Error(`${name} must contain between 1 and ${maximum} characters.`) - } +function context(value: Record) { + return normalizeBoundedJson(value, { + maxArrayItems: 0, + maxBytes: 4_096, + maxDepth: 1, + maxEntries: 8, + maxKeyLength: 100, + maxObjectFields: 8, + maxStringLength: 500, + }) } -function validateContext(context: CreateSecurityAuditInput["context"]) { - try { - return normalizeSecretFreeJson(context, { - maxArrayItems: 16, - maxBytes: 4_096, - maxDepth: 4, - maxEntries: 64, - maxKeyLength: 100, - maxObjectFields: 16, - maxStringLength: 500, - }) - } catch (error) { - if (!(error instanceof Error)) throw error - const message = error.message - .replace("JSON data", "Security audit context") - .replace("JSON arrays", "Security audit context arrays") - .replace("JSON objects", "Security audit context objects") - .replace("JSON keys", "Security audit context keys") - .replace("JSON string values", "Security audit context values") - .replace("JSON numbers", "Security audit context numbers") - .replace("JSON values", "Security audit context values") - throw new Error(message, { cause: error }) +function dataForEvent(event: CreateSecurityAuditEvent): Prisma.SecurityAuditCreateInput { + switch (event.type) { + case "external_identity_created": + return { + id: event.id, + action: "identity.external.created", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "external_identity", + targetId: event.externalIdentityId, + context: context({ provider: event.provider, issuer: event.issuer }), + } + case "external_identity_create_failed": + return { + id: event.id, + action: "identity.external.create_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "external_identity", + context: context({ + provider: event.provider, + issuer: event.issuer, + reason: event.reason, + }), + } + case "provider_revision_selected": + return { + id: event.id, + action: "identity.provider_revision.selected", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "authentication_provider", + targetId: event.providerId, + context: context({ provider: event.providerKey, revision: event.revision }), + } + case "provider_revision_denied": + return { + id: event.id, + action: "identity.provider_revision.denied", + outcome: SecurityAuditOutcome.DENIED, + targetType: "authentication_provider", + context: context({ + provider: event.providerKey, + revision: event.revision, + reason: event.reason, + }), + } + case "provider_revision_persist_failed": + return { + id: event.id, + action: "identity.provider_revision.persist_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "authentication_provider", + context: context({ + provider: event.providerKey, + revision: event.revision, + reason: event.reason, + }), + } } } export function createSecurityAudit( database: CreateSecurityAuditDatabase, - input: CreateSecurityAuditInput, -) { - assertBoundedText("Security audit action", input.action, 100) - assertBoundedText("Security audit target type", input.targetType, 100) - if (input.targetId !== undefined) { - assertBoundedText("Security audit target ID", input.targetId, 100) - } - const context = validateContext(input.context) - - return database.securityAudit.create({ data: { ...input, context } }) + event: CreateSecurityAuditEvent, +): Promise +export function createSecurityAudit(database: CreateSecurityAuditDatabase, input: unknown) { + const event: CreateSecurityAuditEvent = eventSchema.parse(input) + return database.securityAudit.create({ data: dataForEvent(event) }) } diff --git a/hub/server/repositories/security-audits/create-security-audit/types.ts b/hub/server/repositories/security-audits/create-security-audit/types.ts index f084226f..8b9f1bcb 100644 --- a/hub/server/repositories/security-audits/create-security-audit/types.ts +++ b/hub/server/repositories/security-audits/create-security-audit/types.ts @@ -1,5 +1,4 @@ import type { Prisma } from "../../../generated/prisma/client.ts" -import type { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" export interface CreateSecurityAuditDatabase { securityAudit: { @@ -7,20 +6,48 @@ export interface CreateSecurityAuditDatabase { } } -export type SecurityAuditContextValue = - | boolean - | number - | string - | null - | SecurityAuditContextValue[] - | { [key: string]: SecurityAuditContextValue } -export type SecurityAuditContext = Record - -export interface CreateSecurityAuditInput { +interface SecurityAuditEvent { id: string - action: string - outcome: SecurityAuditOutcome - targetType: string - targetId?: string - context: SecurityAuditContext } + +export interface ExternalIdentityCreatedAudit extends SecurityAuditEvent { + type: "external_identity_created" + externalIdentityId: string + issuer: string + provider: string +} + +export interface ExternalIdentityCreateFailedAudit extends SecurityAuditEvent { + type: "external_identity_create_failed" + issuer: string + provider: string + reason: "retry_exhausted" | "transaction_error" +} + +export interface ProviderRevisionSelectedAudit extends SecurityAuditEvent { + type: "provider_revision_selected" + providerId: string + providerKey: string + revision: number +} + +export interface ProviderRevisionDeniedAudit extends SecurityAuditEvent { + type: "provider_revision_denied" + providerKey: string + reason: "invariant" | "revision_gap" | "rollback" | "same_revision_drift" + revision: number +} + +export interface ProviderRevisionPersistFailedAudit extends SecurityAuditEvent { + type: "provider_revision_persist_failed" + providerKey: string + reason: "retry_exhausted" | "transaction_error" + revision: number +} + +export type CreateSecurityAuditEvent = + | ExternalIdentityCreatedAudit + | ExternalIdentityCreateFailedAudit + | ProviderRevisionSelectedAudit + | ProviderRevisionDeniedAudit + | ProviderRevisionPersistFailedAudit diff --git a/hub/server/utils/normalize-bounded-json/index.test.ts b/hub/server/utils/normalize-bounded-json/index.test.ts new file mode 100644 index 00000000..c72bf30c --- /dev/null +++ b/hub/server/utils/normalize-bounded-json/index.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest" + +import type { NormalizeBoundedJsonLimits } from "./types" +import { normalizeBoundedJson } from "." + +function limits(overrides: Partial = {}) { + return { + maxArrayItems: 10, + maxBytes: 1_000, + maxDepth: 5, + maxEntries: 20, + maxKeyLength: 20, + maxObjectFields: 10, + maxStringLength: 100, + ...overrides, + } +} + +describe("normalizeBoundedJson()", () => { + it("accepts the exact UTF-8 byte limit and rejects one byte less", () => { + const input = { label: "界" } + const bytes = Buffer.byteLength(JSON.stringify(input), "utf8") + + expect(normalizeBoundedJson(input, limits({ maxBytes: bytes }))).toEqual(input) + expect(() => normalizeBoundedJson(input, limits({ maxBytes: bytes - 1 }))).toThrow( + `cannot exceed ${bytes - 1} bytes`, + ) + }) + + it("accepts the exact string length and rejects the next character", () => { + expect(normalizeBoundedJson({ label: "abc" }, limits({ maxStringLength: 3 }))).toEqual({ + label: "abc", + }) + expect(() => normalizeBoundedJson({ label: "abcd" }, limits({ maxStringLength: 3 }))).toThrow( + "cannot exceed 3 characters", + ) + }) + + it("accepts the exact key length and rejects the next character", () => { + expect(normalizeBoundedJson({ abc: 1 }, limits({ maxKeyLength: 3 }))).toEqual({ abc: 1 }) + expect(() => normalizeBoundedJson({ abcd: 1 }, limits({ maxKeyLength: 3 }))).toThrow( + "between 1 and 3 characters", + ) + }) + + it("accepts the exact array length and rejects the next item", () => { + expect(normalizeBoundedJson({ values: [1, 2] }, limits({ maxArrayItems: 2 }))).toEqual({ + values: [1, 2], + }) + expect(() => normalizeBoundedJson({ values: [1, 2, 3] }, limits({ maxArrayItems: 2 }))).toThrow( + "cannot exceed 2 items", + ) + }) + + it("accepts the exact object property count and rejects the next property", () => { + expect(normalizeBoundedJson({ first: 1, second: 2 }, limits({ maxObjectFields: 2 }))).toEqual({ + first: 1, + second: 2, + }) + expect(() => + normalizeBoundedJson({ first: 1, second: 2, third: 3 }, limits({ maxObjectFields: 2 })), + ).toThrow("cannot exceed 2 fields") + }) + + it("accepts the exact total entry count and rejects one fewer", () => { + const input = { values: [true, false] } + + expect(normalizeBoundedJson(input, limits({ maxEntries: 3 }))).toEqual(input) + expect(() => normalizeBoundedJson(input, limits({ maxEntries: 2 }))).toThrow( + "cannot exceed 2 total entries", + ) + }) + + it("rejects every non-finite number", () => { + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + expect(() => normalizeBoundedJson({ value }, limits())).toThrow("must be finite") + } + }) + + it("rejects unsupported object prototypes", () => { + class Configuration { + value = "metadata" + } + + for (const value of [new Configuration(), new Date(0), new Map(), new Set()]) { + expect(() => normalizeBoundedJson({ value }, limits())).toThrow( + "only plain objects, arrays, and scalar values", + ) + } + }) + + it("requires an object root while allowing nested scalar and array values", () => { + const nested = { values: [null, true, 1, "text", { enabled: false }] } + + expect(normalizeBoundedJson(nested, limits())).toEqual(nested) + for (const root of [null, true, 1, "text", [1, 2]]) { + expect(() => normalizeBoundedJson(root, limits())).toThrow("must be an object") + } + }) + + it("accepts the exact recursion depth and rejects the next level", () => { + expect( + normalizeBoundedJson({ nested: { value: "at-limit" } }, limits({ maxDepth: 2 })), + ).toEqual({ nested: { value: "at-limit" } }) + expect(() => + normalizeBoundedJson({ nested: { deeper: { value: "too-deep" } } }, limits({ maxDepth: 2 })), + ).toThrow("cannot exceed 2 nested levels") + }) +}) diff --git a/hub/server/utils/normalize-secret-free-json/index.ts b/hub/server/utils/normalize-bounded-json/index.ts similarity index 97% rename from hub/server/utils/normalize-secret-free-json/index.ts rename to hub/server/utils/normalize-bounded-json/index.ts index 1fa37f63..056cf7aa 100644 --- a/hub/server/utils/normalize-secret-free-json/index.ts +++ b/hub/server/utils/normalize-bounded-json/index.ts @@ -1,7 +1,7 @@ import type { NormalizedJsonObject, NormalizedJsonValue, - NormalizeSecretFreeJsonLimits, + NormalizeBoundedJsonLimits, } from "./types.ts" const sensitiveExactNames = new Set(["apikey", "authorization", "cookie"]) @@ -54,9 +54,9 @@ function assertSafeString(value: string, maximum: number) { } } -export function normalizeSecretFreeJson( +export function normalizeBoundedJson( value: unknown, - limits: NormalizeSecretFreeJsonLimits, + limits: NormalizeBoundedJsonLimits, ): NormalizedJsonObject { let entries = 0 diff --git a/hub/server/utils/normalize-secret-free-json/types.ts b/hub/server/utils/normalize-bounded-json/types.ts similarity index 89% rename from hub/server/utils/normalize-secret-free-json/types.ts rename to hub/server/utils/normalize-bounded-json/types.ts index afbb23c4..e571682c 100644 --- a/hub/server/utils/normalize-secret-free-json/types.ts +++ b/hub/server/utils/normalize-bounded-json/types.ts @@ -6,7 +6,7 @@ export interface NormalizedJsonObject { [key: string]: NormalizedJsonValue } -export interface NormalizeSecretFreeJsonLimits { +export interface NormalizeBoundedJsonLimits { maxArrayItems: number maxBytes: number maxDepth: number diff --git a/hub/server/utils/normalize-secret-free-json/index.test.ts b/hub/server/utils/normalize-secret-free-json/index.test.ts deleted file mode 100644 index 50e3aeee..00000000 --- a/hub/server/utils/normalize-secret-free-json/index.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it } from "vitest" - -import type { NormalizeSecretFreeJsonLimits } from "./types" -import { normalizeSecretFreeJson } from "." - -function limits(overrides: Partial = {}) { - return { - maxArrayItems: 10, - maxBytes: 1_000, - maxDepth: 5, - maxEntries: 20, - maxKeyLength: 20, - maxObjectFields: 10, - maxStringLength: 100, - ...overrides, - } -} - -describe("normalizeSecretFreeJson()", () => { - it("accepts the exact UTF-8 byte limit and rejects one byte less", () => { - const input = { label: "界" } - const bytes = Buffer.byteLength(JSON.stringify(input), "utf8") - - expect(normalizeSecretFreeJson(input, limits({ maxBytes: bytes }))).toEqual(input) - expect(() => normalizeSecretFreeJson(input, limits({ maxBytes: bytes - 1 }))).toThrow( - `cannot exceed ${bytes - 1} bytes`, - ) - }) - - it("accepts the exact string length and rejects the next character", () => { - expect(normalizeSecretFreeJson({ label: "abc" }, limits({ maxStringLength: 3 }))).toEqual({ - label: "abc", - }) - expect(() => - normalizeSecretFreeJson({ label: "abcd" }, limits({ maxStringLength: 3 })), - ).toThrow("cannot exceed 3 characters") - }) - - it("accepts the exact key length and rejects the next character", () => { - expect(normalizeSecretFreeJson({ abc: 1 }, limits({ maxKeyLength: 3 }))).toEqual({ abc: 1 }) - expect(() => normalizeSecretFreeJson({ abcd: 1 }, limits({ maxKeyLength: 3 }))).toThrow( - "between 1 and 3 characters", - ) - }) - - it("accepts the exact array length and rejects the next item", () => { - expect(normalizeSecretFreeJson({ values: [1, 2] }, limits({ maxArrayItems: 2 }))).toEqual({ - values: [1, 2], - }) - expect(() => - normalizeSecretFreeJson({ values: [1, 2, 3] }, limits({ maxArrayItems: 2 })), - ).toThrow("cannot exceed 2 items") - }) - - it("accepts the exact object property count and rejects the next property", () => { - expect( - normalizeSecretFreeJson({ first: 1, second: 2 }, limits({ maxObjectFields: 2 })), - ).toEqual({ first: 1, second: 2 }) - expect(() => - normalizeSecretFreeJson({ first: 1, second: 2, third: 3 }, limits({ maxObjectFields: 2 })), - ).toThrow("cannot exceed 2 fields") - }) - - it("accepts the exact total entry count and rejects one fewer", () => { - const input = { values: [true, false] } - - expect(normalizeSecretFreeJson(input, limits({ maxEntries: 3 }))).toEqual(input) - expect(() => normalizeSecretFreeJson(input, limits({ maxEntries: 2 }))).toThrow( - "cannot exceed 2 total entries", - ) - }) - - it("rejects every non-finite number", () => { - for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { - expect(() => normalizeSecretFreeJson({ value }, limits())).toThrow("must be finite") - } - }) - - it("rejects unsupported object prototypes", () => { - class Configuration { - value = "metadata" - } - - for (const value of [new Configuration(), new Date(0), new Map(), new Set()]) { - expect(() => normalizeSecretFreeJson({ value }, limits())).toThrow( - "only plain objects, arrays, and scalar values", - ) - } - }) - - it("requires an object root while allowing nested scalar and array values", () => { - const nested = { values: [null, true, 1, "text", { enabled: false }] } - - expect(normalizeSecretFreeJson(nested, limits())).toEqual(nested) - for (const root of [null, true, 1, "text", [1, 2]]) { - expect(() => normalizeSecretFreeJson(root, limits())).toThrow("must be an object") - } - }) - - it("accepts the exact recursion depth and rejects the next level", () => { - expect( - normalizeSecretFreeJson({ nested: { value: "at-limit" } }, limits({ maxDepth: 2 })), - ).toEqual({ nested: { value: "at-limit" } }) - expect(() => - normalizeSecretFreeJson( - { nested: { deeper: { value: "too-deep" } } }, - limits({ maxDepth: 2 }), - ), - ).toThrow("cannot exceed 2 nested levels") - }) -}) diff --git a/hub/test/database/identity-foundation/index.db.test.ts b/hub/test/database/identity-foundation/index.db.test.ts index 2fd238fd..ab3021d6 100644 --- a/hub/test/database/identity-foundation/index.db.test.ts +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -5,6 +5,29 @@ import { prisma } from "../../../server/integrations/prisma" import { createExternalIdentity } from "../../../server/repositories/external-identities/create-external-identity" import { createUser } from "../../../server/repositories/users/create-user" +async function createProviderAggregate(key: string, digest = "a".repeat(64)) { + const providerId = randomUUID() + const revisionId = randomUUID() + await prisma.$transaction(async (transaction) => { + await transaction.authenticationProvider.create({ + data: { id: providerId, key }, + }) + await transaction.authenticationProviderRevision.create({ + data: { + id: revisionId, + providerId, + revision: 1, + configurationDigest: digest, + }, + }) + await transaction.authenticationProvider.update({ + where: { id: providerId }, + data: { selectedRevisionId: revisionId }, + }) + }) + return { providerId, revisionId } +} + describe("identity foundation migration", () => { it("deploys the committed baseline with only C1 application tables", async () => { const migrations = await prisma.$queryRaw>` @@ -30,6 +53,21 @@ describe("identity foundation migration", () => { { table_name: "security_audits" }, { table_name: "users" }, ]) + const revisionColumns = await prisma.$queryRaw>` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'authentication_provider_revisions' + ORDER BY ordinal_position + ` + expect(revisionColumns.map(({ column_name }) => column_name)).toEqual([ + "id", + "provider_id", + "revision", + "configuration_digest", + "created_at", + "updated_at", + ]) }) it("keeps the identity tuple unique and rejects every identity history change", async () => { @@ -124,25 +162,8 @@ describe("identity foundation migration", () => { }) it("keeps provider revisions and security audit history immutable", async () => { - const providerId = randomUUID() - const revisionId = randomUUID() + const { providerId, revisionId } = await createProviderAggregate("workforce") const auditId = randomUUID() - await prisma.authenticationProvider.create({ - data: { id: providerId, key: "workforce" }, - }) - await prisma.authenticationProviderRevision.create({ - data: { - id: revisionId, - providerId, - revision: 1, - configurationDigest: "a".repeat(64), - settings: { issuer: "https://id.example.test" }, - }, - }) - await prisma.authenticationProvider.update({ - where: { id: providerId }, - data: { selectedRevisionId: revisionId }, - }) await prisma.securityAudit.create({ data: { id: auditId, @@ -157,7 +178,7 @@ describe("identity foundation migration", () => { await expect( prisma.authenticationProviderRevision.update({ where: { id: revisionId }, - data: { settings: { issuer: "https://changed.example.test" } }, + data: { configurationDigest: "b".repeat(64) }, }), ).rejects.toThrow("Authentication provider revisions are immutable") await expect( @@ -202,23 +223,16 @@ describe("identity foundation migration", () => { expect(provider?.selectedRevision?.id).toBe(revisionId) }) - it("rejects foreign-key violations and selecting another provider's revision", async () => { - const firstProviderId = randomUUID() - const secondProviderId = randomUUID() - const secondRevisionId = randomUUID() - await prisma.authenticationProvider.createMany({ - data: [ - { id: firstProviderId, key: "first" }, - { id: secondProviderId, key: "second" }, - ], - }) + it("requires every committed provider to select its own existing revision", async () => { + const first = await createProviderAggregate("first") + const second = await createProviderAggregate("second", "b".repeat(64)) + const unselectedSecondRevisionId = randomUUID() await prisma.authenticationProviderRevision.create({ data: { - id: secondRevisionId, - providerId: secondProviderId, - revision: 1, - configurationDigest: "b".repeat(64), - settings: { issuer: "https://id.example.test" }, + id: unselectedSecondRevisionId, + providerId: second.providerId, + revision: 2, + configurationDigest: "c".repeat(64), }, }) @@ -240,16 +254,60 @@ describe("identity foundation migration", () => { providerId: randomUUID(), revision: 1, configurationDigest: "c".repeat(64), - settings: {}, }, }), ).rejects.toMatchObject({ code: "P2003" }) await expect( prisma.authenticationProvider.update({ - where: { id: firstProviderId }, - data: { selectedRevisionId: secondRevisionId }, + where: { id: first.providerId }, + data: { selectedRevisionId: unselectedSecondRevisionId }, }), ).rejects.toThrow("Selected revision must belong to its authentication provider") + await expect( + prisma.authenticationProvider.update({ + where: { id: first.providerId }, + data: { selectedRevisionId: randomUUID() }, + }), + ).rejects.toMatchObject({ code: "P2003" }) + await expect( + prisma.authenticationProviderRevision.delete({ + where: { id: first.revisionId }, + }), + ).rejects.toThrow("Authentication provider revisions are immutable") + await expect( + prisma.authenticationProvider.create({ + data: { id: randomUUID(), key: "unselected" }, + }), + ).rejects.toThrow("Authentication provider must have a selected revision") + }) + + it("allows atomic provider creation and selection of the next owned revision", async () => { + const first = await createProviderAggregate("workforce") + const nextRevisionId = randomUUID() + + await prisma.$transaction(async (transaction) => { + await transaction.authenticationProviderRevision.create({ + data: { + id: nextRevisionId, + providerId: first.providerId, + revision: 2, + configurationDigest: "b".repeat(64), + }, + }) + await transaction.authenticationProvider.update({ + where: { id: first.providerId }, + data: { selectedRevisionId: nextRevisionId }, + }) + }) + + await expect( + prisma.authenticationProvider.findUnique({ + where: { id: first.providerId }, + include: { selectedRevision: true }, + }), + ).resolves.toMatchObject({ + selectedRevision: { id: nextRevisionId, revision: 2 }, + }) }) it("rejects audit context that exceeds the migration byte bound", async () => { From e31e740fc225aacb01111870222459093b61c58b Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 15:24:49 -0700 Subject: [PATCH 5/8] fix(hub): own identity retry and validation audit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/architecture.md | 2 +- hub/docs/backend-architecture.md | 2 +- hub/server/domains/identity/errors.ts | 7 ++ .../index.db.test.ts | 16 ++--- .../index.ts | 5 +- .../types.ts | 2 - .../run-with-bounded-retries/index.test.ts | 58 +++++++-------- .../run-with-bounded-retries/index.ts | 34 +++------ .../run-with-bounded-retries/types.ts | 10 --- .../index.db.test.ts | 70 +++++++++++++++---- .../index.ts | 47 +++++++++---- .../types.ts | 2 - .../create-security-audit/index.test.ts | 11 +++ .../create-security-audit/index.ts | 20 ++++++ .../create-security-audit/types.ts | 7 ++ 15 files changed, 177 insertions(+), 116 deletions(-) delete mode 100644 hub/server/domains/identity/run-with-bounded-retries/types.ts diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 190931b9..123835c6 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -28,7 +28,7 @@ The [WebSocket foundation](websockets.md) attaches to the API's HTTP server and The identity foundation stores Hub users, immutable external identity mappings, selected authentication provider revision digests, and bounded security audits. It does not store provider configuration. A provider adapter must validate its typed public configuration before the kernel computes a digest and starts the database transaction. -Domain operations receive only the transaction-scoped repositories they need. A successful state change and its success audit commit together. Expected serializable and aggregate-creation races retry the complete database operation against a fresh snapshot. A rejected operation rolls back before its final denial or failure audit is written. Audit callers select a typed kernel event; the repository builds the stored action and allowlisted context. +Domain operations receive only the transaction-scoped repositories they need. A successful state change and its success audit commit together. The kernel applies one fixed bounded retry policy to expected serializable and aggregate-creation races so consumers cannot disable convergence. A rejected database operation rolls back before its final denial or failure audit is written. External provider-validation failures write one typed audit after validation returns and before the operation fails. Audit callers select a typed kernel event; the repository builds the stored action and allowlisted context. This foundation does not prove a provider assertion, create a usable Hub session, assign membership, or authorize product access. diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 8f8fd557..c1bb39d0 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -47,4 +47,4 @@ The identity kernel keeps database access in focused repositories for users, ext Keep network, provider, and filesystem work outside the transaction. Provider validation returns canonical public configuration, and the kernel computes and stores only its digest. A transaction may change one identity or authentication-provider aggregate and write its success audit. -Retry expected serialization and aggregate-creation conflicts around the complete transaction so the next attempt rereads current state. Bound attempts and backoff. Do not retry invariant or foreign-key failures. Write a denial or failure audit only after the state transaction has rolled back and the operation has reached its terminal result. +The identity kernel owns one fixed bounded retry count and backoff for expected serialization and aggregate-creation conflicts around the complete transaction. Consumers cannot configure or disable this policy. Do not retry invariant or foreign-key failures. Write a database denial or failure audit only after the state transaction has rolled back and the operation has reached its terminal result. Write a typed provider-validation failure audit after external validation rejects and before returning the safe kernel error. diff --git a/hub/server/domains/identity/errors.ts b/hub/server/domains/identity/errors.ts index f33a806a..149a5d31 100644 --- a/hub/server/domains/identity/errors.ts +++ b/hub/server/domains/identity/errors.ts @@ -22,6 +22,13 @@ export class ProviderRevisionConflictError extends Error { } } +export class ProviderConfigurationValidationError extends Error { + constructor(options?: ErrorOptions) { + super("The provider configuration did not pass provider validation.", options) + this.name = "ProviderConfigurationValidationError" + } +} + export class ProviderRevisionInvariantError extends Error { constructor(message: string) { super(message) diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts index d9dcac78..8961e1e2 100644 --- a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto" -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { Prisma } from "../../../generated/prisma/client" import { prisma } from "../../../integrations/prisma" @@ -24,12 +24,6 @@ const tuple = { function dependencies(createId = randomUUID) { return { createId, - retry: { - baseDelayMs: 0, - maxAttempts: 3, - maxDelayMs: 0, - sleep: () => Promise.resolve(), - }, runTransaction: ( operation: (repositories: IdentityCreationRepositories) => Promise, ) => runIdentityCreationTransaction(prisma, operation), @@ -139,14 +133,12 @@ describe("resolveOrCreateExternalIdentity()", () => { it("writes one failure audit only after the retry budget is exhausted", async () => { const collidedUserId = randomUUID() await createUser(prisma, { id: collidedUserId }) - const configured = dependencies(() => collidedUserId) + const createId = vi.fn(() => collidedUserId) await expect( - resolveOrCreateExternalIdentity(tuple, { - ...configured, - retry: { ...configured.retry, maxAttempts: 2 }, - }), + resolveOrCreateExternalIdentity(tuple, dependencies(createId)), ).rejects.toBeInstanceOf(ExternalIdentityCollisionError) + expect(createId).toHaveBeenCalledTimes(7) await expect(prisma.externalIdentity.count()).resolves.toBe(0) await expect(prisma.securityAudit.findMany()).resolves.toEqual([ expect.objectContaining({ diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/index.ts b/hub/server/domains/identity/resolve-or-create-external-identity/index.ts index 58b9d73e..950b0985 100644 --- a/hub/server/domains/identity/resolve-or-create-external-identity/index.ts +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.ts @@ -46,10 +46,7 @@ export async function resolveOrCreateExternalIdentity( return { status: "created", externalIdentityId, userId } }), - { - ...dependencies.retry, - shouldRetry: (error) => error instanceof IdentityTransactionConflictError, - }, + (error) => error instanceof IdentityTransactionConflictError, ) } catch (error) { const failure = diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/types.ts b/hub/server/domains/identity/resolve-or-create-external-identity/types.ts index 1ee7b1e1..27cf4586 100644 --- a/hub/server/domains/identity/resolve-or-create-external-identity/types.ts +++ b/hub/server/domains/identity/resolve-or-create-external-identity/types.ts @@ -1,10 +1,8 @@ import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" import type { IdentityCreationTransaction } from "../run-identity-creation-transaction/types.ts" -import type { TransactionRetryOptions } from "../run-with-bounded-retries/types.ts" export interface ResolveOrCreateExternalIdentityDependencies { createId(): string - retry: TransactionRetryOptions runTransaction: IdentityCreationTransaction writeAudit(input: CreateSecurityAuditEvent): Promise } diff --git a/hub/server/domains/identity/run-with-bounded-retries/index.test.ts b/hub/server/domains/identity/run-with-bounded-retries/index.test.ts index 414c9c97..e04581b8 100644 --- a/hub/server/domains/identity/run-with-bounded-retries/index.test.ts +++ b/hub/server/domains/identity/run-with-bounded-retries/index.test.ts @@ -4,58 +4,50 @@ import { IdentityTransactionConflictError } from "../errors" import { runWithBoundedRetries } from "." describe("runWithBoundedRetries()", () => { - it("retries retryable failures with bounded exponential delays", async () => { + it("uses the kernel-owned three-attempt budget and bounded delays", async () => { + vi.useFakeTimers() const operation = vi .fn<(attempt: number) => Promise>() .mockRejectedValueOnce(new IdentityTransactionConflictError()) .mockRejectedValueOnce(new IdentityTransactionConflictError()) .mockResolvedValue("recovered") - const sleep = vi.fn<(delayMs: number) => Promise>().mockResolvedValue(undefined) - await expect( - runWithBoundedRetries(operation, { - baseDelayMs: 5, - maxAttempts: 3, - maxDelayMs: 8, - shouldRetry: (error) => error instanceof IdentityTransactionConflictError, - sleep, - }), - ).resolves.toBe("recovered") + const result = runWithBoundedRetries( + operation, + (error) => error instanceof IdentityTransactionConflictError, + ) + await vi.advanceTimersByTimeAsync(15) + + await expect(result).resolves.toBe("recovered") expect(operation.mock.calls.map(([attempt]) => attempt)).toEqual([1, 2, 3]) - expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5, 8]) }) - it("stops after the configured attempt budget", async () => { + it("stops at the fixed bound without consumer configuration", async () => { + vi.useFakeTimers() const failure = new IdentityTransactionConflictError() const operation = vi.fn(() => Promise.reject(failure)) - await expect( - runWithBoundedRetries(operation, { - baseDelayMs: 0, - maxAttempts: 2, - maxDelayMs: 0, - shouldRetry: (error) => error instanceof IdentityTransactionConflictError, - sleep: () => Promise.resolve(), - }), - ).rejects.toBe(failure) - expect(operation).toHaveBeenCalledTimes(2) + const result = runWithBoundedRetries( + operation, + (error) => error instanceof IdentityTransactionConflictError, + ) + const assertion = expect(result).rejects.toBe(failure) + await vi.runAllTimersAsync() + + await assertion + expect(operation).toHaveBeenCalledTimes(3) }) - it("does not retry non-retryable failures", async () => { + it("fails non-retryable errors once", async () => { const failure = new Error("non-retryable") const operation = vi.fn(() => Promise.reject(failure)) - const sleep = vi.fn<(delayMs: number) => Promise>() await expect( - runWithBoundedRetries(operation, { - baseDelayMs: 5, - maxAttempts: 3, - maxDelayMs: 10, - shouldRetry: (error) => error instanceof IdentityTransactionConflictError, - sleep, - }), + runWithBoundedRetries( + operation, + (error) => error instanceof IdentityTransactionConflictError, + ), ).rejects.toBe(failure) expect(operation).toHaveBeenCalledOnce() - expect(sleep).not.toHaveBeenCalled() }) }) diff --git a/hub/server/domains/identity/run-with-bounded-retries/index.ts b/hub/server/domains/identity/run-with-bounded-retries/index.ts index f983abc7..7902725d 100644 --- a/hub/server/domains/identity/run-with-bounded-retries/index.ts +++ b/hub/server/domains/identity/run-with-bounded-retries/index.ts @@ -1,33 +1,21 @@ -import type { RunWithBoundedRetriesInput } from "./types.ts" +const maxAttempts = 3 +const baseDelayMs = 5 +const maxDelayMs = 20 + +function sleep(delayMs: number) { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} export async function runWithBoundedRetries( operation: (attempt: number) => Promise, - options: RunWithBoundedRetriesInput, + shouldRetry: (error: unknown) => boolean, ): Promise { - if ( - !Number.isSafeInteger(options.maxAttempts) || - options.maxAttempts < 1 || - options.maxAttempts > 5 - ) { - throw new Error("Transaction retry attempts must be between 1 and 5.") - } - if ( - !Number.isSafeInteger(options.baseDelayMs) || - options.baseDelayMs < 0 || - !Number.isSafeInteger(options.maxDelayMs) || - options.maxDelayMs < options.baseDelayMs || - options.maxDelayMs > 30_000 - ) { - throw new Error("Transaction retry delays must be bounded between 0 and 30000 milliseconds.") - } - - for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { return await operation(attempt) } catch (error) { - if (attempt === options.maxAttempts || !options.shouldRetry(error)) throw error - const delay = Math.min(options.baseDelayMs * 2 ** (attempt - 1), options.maxDelayMs) - await options.sleep(delay) + if (attempt === maxAttempts || !shouldRetry(error)) throw error + await sleep(Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs)) } } throw new Error("Transaction retry loop ended without a result.") diff --git a/hub/server/domains/identity/run-with-bounded-retries/types.ts b/hub/server/domains/identity/run-with-bounded-retries/types.ts deleted file mode 100644 index bd4ea836..00000000 --- a/hub/server/domains/identity/run-with-bounded-retries/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface TransactionRetryOptions { - baseDelayMs: number - maxAttempts: number - maxDelayMs: number - sleep(delayMs: number): Promise -} - -export interface RunWithBoundedRetriesInput extends TransactionRetryOptions { - shouldRetry(error: unknown): boolean -} diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts index f934da8d..3aa8db20 100644 --- a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts @@ -11,6 +11,7 @@ import { selectAuthenticationProviderRevision } from "../../../repositories/auth import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit" import { IdentityTransactionConflictError, + ProviderConfigurationValidationError, ProviderRevisionDriftError, ProviderRevisionGapError, ProviderRevisionRollbackError, @@ -42,12 +43,6 @@ function dependencies( ) { return { createId: randomUUID, - retry: { - baseDelayMs: 0, - maxAttempts: 3, - maxDelayMs: 0, - sleep: () => Promise.resolve(), - }, runTransaction: ( operation: (repositories: ProviderRevisionRepositories) => Promise, ) => runProviderRevisionTransaction(prisma, operation), @@ -151,11 +146,15 @@ describe("validateAndPersistProviderRevision()", () => { expect(records[0]?.configurationDigest).toMatch(/^[0-9a-f]{64}$/) }) - it("does not enter a transaction or write an audit when validation fails", async () => { - const validator = vi.fn(() => Promise.reject(new Error("provider validation failed"))) + it("writes one safe terminal audit outside transactions when validation fails", async () => { + const rawErrorMarker = "raw-validator-error-marker" + const order: string[] = [] + const validator = vi.fn(() => { + order.push("validate") + return Promise.reject(new Error(rawErrorMarker)) + }) const base = dependencies(validator) let transactionCalls = 0 - let auditCalls = 0 async function runTransaction( operation: (repositories: ProviderRevisionRepositories) => Promise, ) { @@ -163,7 +162,7 @@ describe("validateAndPersistProviderRevision()", () => { return base.runTransaction(operation) } async function writeAudit(input: Parameters[1]) { - auditCalls += 1 + order.push("audit") return base.writeAudit(input) } @@ -173,14 +172,47 @@ describe("validateAndPersistProviderRevision()", () => { runTransaction, writeAudit, }), - ).rejects.toThrow("provider validation failed") + ).rejects.toBeInstanceOf(ProviderConfigurationValidationError) + expect(order).toEqual(["validate", "audit"]) + expect(transactionCalls).toBe(0) + await expect(prisma.authenticationProvider.count()).resolves.toBe(0) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(0) + const audits = await prisma.securityAudit.findMany() + expect(audits).toHaveLength(1) + expect(audits[0]?.action).toBe("identity.provider_revision.validation_failed") + expect(audits[0]?.outcome).toBe("FAILURE") + expect(audits[0]?.context).toEqual({ + provider: "workforce", + reason: "validation_failed", + revision: 1, + }) + expect(JSON.stringify(audits)).not.toContain(rawErrorMarker) + }) + + it("reports both validation and audit failures without entering a transaction", async () => { + const base = dependencies(() => Promise.reject(new Error("validation marker"))) + let transactionCalls = 0 + async function runTransaction( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ) { + transactionCalls += 1 + return base.runTransaction(operation) + } + + await expect( + validateAndPersistProviderRevision(input(1), { + ...base, + runTransaction, + writeAudit: () => Promise.reject(new Error("audit marker")), + }), + ).rejects.toBeInstanceOf(AggregateError) expect(transactionCalls).toBe(0) - expect(auditCalls).toBe(0) + await expect(prisma.authenticationProvider.count()).resolves.toBe(0) await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(0) await expect(prisma.securityAudit.count()).resolves.toBe(0) }) - it("rejects unknown configuration and caller-supplied digest fields before persistence", async () => { + it("audits rejected configuration but rejects caller-supplied digest before validation", async () => { const base = dependencies() const configurationWithUnknownField = { audience: "hub", @@ -200,10 +232,15 @@ describe("validateAndPersistProviderRevision()", () => { dependencies((candidate) => validateConfiguration(candidate)), ), ).rejects.toThrow() + await expect( + prisma.securityAudit.count({ + where: { action: "identity.provider_revision.validation_failed" }, + }), + ).resolves.toBe(1) await expect(validateAndPersistProviderRevision(inputWithDigest, base)).rejects.toThrow() await expect(prisma.authenticationProvider.count()).resolves.toBe(0) await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(0) - await expect(prisma.securityAudit.count()).resolves.toBe(0) + await expect(prisma.securityAudit.count()).resolves.toBe(1) }) it("selects monotonic revisions and returns unchanged for the same validated configuration", async () => { @@ -219,6 +256,11 @@ describe("validateAndPersistProviderRevision()", () => { }) await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(2) await expect(prisma.securityAudit.count({ where: { outcome: "SUCCESS" } })).resolves.toBe(2) + await expect( + prisma.securityAudit.count({ + where: { action: "identity.provider_revision.validation_failed" }, + }), + ).resolves.toBe(0) }) it("retries the database operation without repeating external validation", async () => { diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts index ba04dc3d..6e4dd74f 100644 --- a/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { IdentityTransactionConflictError, + ProviderConfigurationValidationError, ProviderRevisionConflictError, ProviderRevisionDriftError, ProviderRevisionGapError, @@ -45,18 +46,39 @@ export async function validateAndPersistProviderRevision( throw new Error("Provider revision input must be a plain object.") } const parsed = providerRevisionSchema.parse(input) - const validated = await dependencies.validateConfiguration(input.configuration) - if ( - typeof validated.canonicalPublicConfiguration !== "string" || - !validated.canonicalPublicConfiguration.length || - validated.canonicalPublicConfiguration.length > 16_384 - ) { - throw new Error( - "Provider validation must return canonical public configuration between 1 and 16384 characters.", - ) + let canonicalPublicConfiguration: string + try { + const validated = await dependencies.validateConfiguration(input.configuration) + if ( + typeof validated.canonicalPublicConfiguration !== "string" || + !validated.canonicalPublicConfiguration.length || + validated.canonicalPublicConfiguration.length > 16_384 + ) { + throw new Error( + "Provider validation must return canonical public configuration between 1 and 16384 characters.", + ) + } + canonicalPublicConfiguration = validated.canonicalPublicConfiguration + } catch (error) { + const reportedError = new ProviderConfigurationValidationError({ cause: error }) + try { + await dependencies.writeAudit({ + id: dependencies.createId(), + type: "provider_revision_validation_failed", + providerKey: parsed.providerKey, + revision: parsed.revision, + }) + } catch (auditError) { + throw new AggregateError( + [reportedError, auditError], + "The provider validation failure and its audit could not both be recorded.", + { cause: auditError }, + ) + } + throw reportedError } const configurationDigest = createHash("sha256") - .update(validated.canonicalPublicConfiguration) + .update(canonicalPublicConfiguration) .digest("hex") try { @@ -108,10 +130,7 @@ export async function validateAndPersistProviderRevision( return { status: "selected", providerId, revisionId, revision: parsed.revision } }), - { - ...dependencies.retry, - shouldRetry: (error) => error instanceof IdentityTransactionConflictError, - }, + (error) => error instanceof IdentityTransactionConflictError, ) } catch (error) { const reason = denialReason(error) diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/types.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/types.ts index 8abc0c87..4704fa1a 100644 --- a/hub/server/domains/identity/validate-and-persist-provider-revision/types.ts +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/types.ts @@ -1,6 +1,5 @@ import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" import type { ProviderRevisionTransaction } from "../run-provider-revision-transaction/types.ts" -import type { TransactionRetryOptions } from "../run-with-bounded-retries/types.ts" export interface ValidatedProviderConfiguration { canonicalPublicConfiguration: string @@ -8,7 +7,6 @@ export interface ValidatedProviderConfiguration { export interface ValidateAndPersistProviderRevisionDependencies { createId(): string - retry: TransactionRetryOptions runTransaction: ProviderRevisionTransaction validateConfiguration(candidate: Candidate): Promise writeAudit(input: CreateSecurityAuditEvent): Promise diff --git a/hub/server/repositories/security-audits/create-security-audit/index.test.ts b/hub/server/repositories/security-audits/create-security-audit/index.test.ts index 48b4d1f4..3b90a244 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.test.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.test.ts @@ -24,6 +24,12 @@ describe("createSecurityAudit()", () => { issuer: "https://id.example.test", reason: "retry_exhausted", }) + await createSecurityAudit(database, { + id, + type: "provider_revision_validation_failed", + providerKey: "workforce", + revision: 1, + }) await createSecurityAudit(database, { id, type: "provider_revision_selected", @@ -62,6 +68,11 @@ describe("createSecurityAudit()", () => { reason: "retry_exhausted", }, }), + expect.objectContaining({ + action: "identity.provider_revision.validation_failed", + outcome: "FAILURE", + context: { provider: "workforce", reason: "validation_failed", revision: 1 }, + }), expect.objectContaining({ action: "identity.provider_revision.selected", outcome: "SUCCESS", diff --git a/hub/server/repositories/security-audits/create-security-audit/index.ts b/hub/server/repositories/security-audits/create-security-audit/index.ts index e923e523..f2435e92 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.ts @@ -23,6 +23,14 @@ const eventSchema = z.discriminatedUnion("type", [ reason: z.enum(["retry_exhausted", "transaction_error"]), }) .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("provider_revision_validation_failed"), + providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + revision: z.number().int().positive(), + }) + .strict(), z .object({ id: z.uuid(), @@ -96,6 +104,18 @@ function dataForEvent(event: CreateSecurityAuditEvent): Prisma.SecurityAuditCrea targetId: event.providerId, context: context({ provider: event.providerKey, revision: event.revision }), } + case "provider_revision_validation_failed": + return { + id: event.id, + action: "identity.provider_revision.validation_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "authentication_provider", + context: context({ + provider: event.providerKey, + revision: event.revision, + reason: "validation_failed", + }), + } case "provider_revision_denied": return { id: event.id, diff --git a/hub/server/repositories/security-audits/create-security-audit/types.ts b/hub/server/repositories/security-audits/create-security-audit/types.ts index 8b9f1bcb..ed56b7b2 100644 --- a/hub/server/repositories/security-audits/create-security-audit/types.ts +++ b/hub/server/repositories/security-audits/create-security-audit/types.ts @@ -31,6 +31,12 @@ export interface ProviderRevisionSelectedAudit extends SecurityAuditEvent { revision: number } +export interface ProviderRevisionValidationFailedAudit extends SecurityAuditEvent { + type: "provider_revision_validation_failed" + providerKey: string + revision: number +} + export interface ProviderRevisionDeniedAudit extends SecurityAuditEvent { type: "provider_revision_denied" providerKey: string @@ -48,6 +54,7 @@ export interface ProviderRevisionPersistFailedAudit extends SecurityAuditEvent { export type CreateSecurityAuditEvent = | ExternalIdentityCreatedAudit | ExternalIdentityCreateFailedAudit + | ProviderRevisionValidationFailedAudit | ProviderRevisionSelectedAudit | ProviderRevisionDeniedAudit | ProviderRevisionPersistFailedAudit From 78ad72e4958a47beac839c41e73bdef46e1ca375 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 09:34:09 -0700 Subject: [PATCH 6/8] fix(hub): bound external identity index keys Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/database.md | 2 + .../migration.sql | 3 ++ hub/server/domains/identity/errors.ts | 7 +++ .../index.test.ts | 42 ++++++++++++++++++ .../parse-external-identity-tuple/index.ts | 13 +++++- .../index.db.test.ts | 41 +++++------------- .../index.test.ts | 39 +++++++++++++++++ .../index.ts | 9 +++- .../types.ts | 15 ++++++- .../index.test.ts | 39 +++++++++++++++++ .../index.ts | 9 +++- .../types.ts | 15 ++++++- .../index.db.test.ts | 43 ++++--------------- .../identity-foundation/index.db.test.ts | 32 ++++++++++++++ 14 files changed, 235 insertions(+), 74 deletions(-) create mode 100644 hub/server/domains/identity/parse-external-identity-tuple/index.test.ts create mode 100644 hub/server/domains/identity/run-identity-creation-transaction/index.test.ts create mode 100644 hub/server/domains/identity/run-provider-revision-transaction/index.test.ts diff --git a/hub/docs/database.md b/hub/docs/database.md index 04405744..4cc1f370 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -2,6 +2,8 @@ Hub uses PostgreSQL and Prisma. The schema contains the provider-neutral identity transaction foundation. Authentication provider revisions store a validated-configuration digest, not provider configuration, and every committed provider must select one of its own revisions. Hub sessions, memberships, invitations, source integrations, and product records are not part of this foundation. +An external identity's provider, issuer, and subject share a 2,000-byte UTF-8 limit, including two separator bytes. Runtime parsing and the committed database constraint enforce the same conservative bound so the composite unique index remains below PostgreSQL's B-tree item limit. + [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. [Compose](../.devcontainer/compose.yaml) pins the development database image. [package.json](../package.json) pins the Prisma packages to the same version. Dependency updates must follow the [seven-day dependency policy](../../docs/testing-and-review.md#update-dependencies). diff --git a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql index e69f9ed6..f6a2059f 100644 --- a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql +++ b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql @@ -20,6 +20,9 @@ CREATE TABLE "external_identities" ( CONSTRAINT "external_identities_pkey" PRIMARY KEY ("id"), CONSTRAINT "external_identities_values_present" CHECK ( length("provider") > 0 AND length("issuer") > 0 AND length("subject") > 0 + ), + CONSTRAINT "external_identities_tuple_index_bytes" CHECK ( + octet_length("provider") + octet_length("issuer") + octet_length("subject") + 2 <= 2000 ) ); diff --git a/hub/server/domains/identity/errors.ts b/hub/server/domains/identity/errors.ts index 149a5d31..16743d65 100644 --- a/hub/server/domains/identity/errors.ts +++ b/hub/server/domains/identity/errors.ts @@ -15,6 +15,13 @@ export class ExternalIdentityCollisionError extends Error { } } +export class ExternalIdentityTupleTooLargeError extends Error { + constructor() { + super("The external identity tuple exceeds the 2000-byte UTF-8 index limit.") + this.name = "ExternalIdentityTupleTooLargeError" + } +} + export class ProviderRevisionConflictError extends Error { constructor(options?: ErrorOptions) { super("The provider revision conflicted with another provider configuration change.", options) diff --git a/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts b/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts new file mode 100644 index 00000000..00e7c4e3 --- /dev/null +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest" + +import { ExternalIdentityTupleTooLargeError } from "../errors" +import { externalIdentityTupleMaxBytes, parseExternalIdentityTuple } from "." + +const provider = "p" +const issuer = "界".repeat(500) +const subjectAtLimit = `${"界".repeat(165)}aa` + +describe("parseExternalIdentityTuple()", () => { + it("accepts the exact combined UTF-8 index byte limit", () => { + const tuple = { provider, issuer, subject: subjectAtLimit } + const bytes = + Buffer.byteLength(provider) + + Buffer.byteLength(issuer) + + Buffer.byteLength(subjectAtLimit) + + 2 + + expect(bytes).toBe(externalIdentityTupleMaxBytes) + expect(parseExternalIdentityTuple(tuple)).toEqual(tuple) + }) + + it("rejects one byte over the combined UTF-8 index limit", () => { + expect(() => + parseExternalIdentityTuple({ + provider, + issuer, + subject: `${subjectAtLimit}a`, + }), + ).toThrow(ExternalIdentityTupleTooLargeError) + }) + + it("preserves ordinary provider identity sizes", () => { + const tuple = { + provider: "github", + issuer: "https://github.com/login/oauth", + subject: "123456789", + } + + expect(parseExternalIdentityTuple(tuple)).toEqual(tuple) + }) +}) diff --git a/hub/server/domains/identity/parse-external-identity-tuple/index.ts b/hub/server/domains/identity/parse-external-identity-tuple/index.ts index 557d35c2..e05e7d27 100644 --- a/hub/server/domains/identity/parse-external-identity-tuple/index.ts +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.ts @@ -1,7 +1,9 @@ import { z } from "zod" import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" +import { ExternalIdentityTupleTooLargeError } from "../errors.ts" +export const externalIdentityTupleMaxBytes = 2_000 const externalIdentityTupleSchema = z .object({ provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), @@ -18,5 +20,14 @@ export function parseExternalIdentityTuple(input: unknown): ExternalIdentityTupl ) { throw new Error("External identity input must be a plain object.") } - return externalIdentityTupleSchema.parse(input) + const tuple = externalIdentityTupleSchema.parse(input) + const bytes = + Buffer.byteLength(tuple.provider, "utf8") + + Buffer.byteLength(tuple.issuer, "utf8") + + Buffer.byteLength(tuple.subject, "utf8") + + 2 + if (bytes > externalIdentityTupleMaxBytes) { + throw new ExternalIdentityTupleTooLargeError() + } + return tuple } diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts index 8961e1e2..f7487ea4 100644 --- a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts @@ -3,11 +3,10 @@ import { describe, expect, it, vi } from "vitest" import { Prisma } from "../../../generated/prisma/client" import { prisma } from "../../../integrations/prisma" -import { createExternalIdentity } from "../../../repositories/external-identities/create-external-identity" import { findExternalIdentity } from "../../../repositories/external-identities/find-external-identity" import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit" import { createUser } from "../../../repositories/users/create-user" -import { ExternalIdentityCollisionError, IdentityTransactionConflictError } from "../errors" +import { ExternalIdentityCollisionError } from "../errors" import { runIdentityCreationTransaction } from "../run-identity-creation-transaction" import type { IdentityCreationRepositories, @@ -39,36 +38,16 @@ function controlledConcurrentTransaction(): IdentityCreationTransaction { release = resolve }) - return async (operation) => { - try { - return await prisma.$transaction( - (transaction) => - operation({ - createExternalIdentity: (input) => createExternalIdentity(transaction, input), - createSecurityAudit: (input) => createSecurityAudit(transaction, input), - createUser: (input) => createUser(transaction, input), - findExternalIdentity: async (input) => { - const identity = await findExternalIdentity(transaction, input) - if (!identity) { - arrivals += 1 - if (arrivals === 2) release?.() - await overlap - } - return identity - }, - }), - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, - ) - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - ["P2002", "P2003", "P2034"].includes(error.code) - ) { - throw new IdentityTransactionConflictError({ cause: error }) + return (operation) => + runIdentityCreationTransaction(prisma, operation, async (database, input) => { + const identity = await findExternalIdentity(database, input) + if (!identity) { + arrivals += 1 + if (arrivals === 2) release?.() + await overlap } - throw error - } - } + return identity + }) } describe("resolveOrCreateExternalIdentity()", () => { diff --git a/hub/server/domains/identity/run-identity-creation-transaction/index.test.ts b/hub/server/domains/identity/run-identity-creation-transaction/index.test.ts new file mode 100644 index 00000000..c3b1080b --- /dev/null +++ b/hub/server/domains/identity/run-identity-creation-transaction/index.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" + +import { Prisma } from "../../../generated/prisma/client" +import { IdentityTransactionConflictError } from "../errors" +import type { IdentityDatabaseClient } from "./types" +import { runIdentityCreationTransaction } from "." + +function databaseError(code: string) { + return new Prisma.PrismaClientKnownRequestError("database failure", { + clientVersion: "7.10.1", + code, + }) +} + +function rejectingDatabase(error: Error): IdentityDatabaseClient { + return { + $transaction: () => Promise.reject(error), + } +} + +describe("runIdentityCreationTransaction()", () => { + it.each(["P2002", "P2034"])("translates retryable %s errors", async (code) => { + await expect( + runIdentityCreationTransaction(rejectingDatabase(databaseError(code)), () => + Promise.resolve("unreachable"), + ), + ).rejects.toBeInstanceOf(IdentityTransactionConflictError) + }) + + it("leaves foreign-key failures non-retryable", async () => { + const error = databaseError("P2003") + + await expect( + runIdentityCreationTransaction(rejectingDatabase(error), () => + Promise.resolve("unreachable"), + ), + ).rejects.toBe(error) + }) +}) diff --git a/hub/server/domains/identity/run-identity-creation-transaction/index.ts b/hub/server/domains/identity/run-identity-creation-transaction/index.ts index ccc4eacd..5f87a961 100644 --- a/hub/server/domains/identity/run-identity-creation-transaction/index.ts +++ b/hub/server/domains/identity/run-identity-creation-transaction/index.ts @@ -4,11 +4,16 @@ import { findExternalIdentity } from "../../../repositories/external-identities/ import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" import { createUser } from "../../../repositories/users/create-user/index.ts" import { IdentityTransactionConflictError } from "../errors.ts" -import type { IdentityCreationRepositories, IdentityDatabaseClient } from "./types.ts" +import type { + FindExternalIdentityForTransaction, + IdentityCreationRepositories, + IdentityDatabaseClient, +} from "./types.ts" export function runIdentityCreationTransaction( database: IdentityDatabaseClient, operation: (repositories: IdentityCreationRepositories) => Promise, + findIdentity: FindExternalIdentityForTransaction = findExternalIdentity, ): Promise { return database .$transaction( @@ -17,7 +22,7 @@ export function runIdentityCreationTransaction( createExternalIdentity: (input) => createExternalIdentity(transaction, input), createSecurityAudit: (input) => createSecurityAudit(transaction, input), createUser: (input) => createUser(transaction, input), - findExternalIdentity: (tuple) => findExternalIdentity(transaction, tuple), + findExternalIdentity: (tuple) => findIdentity(transaction, tuple), }), { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ) diff --git a/hub/server/domains/identity/run-identity-creation-transaction/types.ts b/hub/server/domains/identity/run-identity-creation-transaction/types.ts index 215fb348..892d14a5 100644 --- a/hub/server/domains/identity/run-identity-creation-transaction/types.ts +++ b/hub/server/domains/identity/run-identity-creation-transaction/types.ts @@ -1,5 +1,6 @@ -import type { PrismaClient } from "../../../generated/prisma/client.ts" +import type { Prisma } from "../../../generated/prisma/client.ts" import type { CreateExternalIdentityInput } from "../../../repositories/external-identities/create-external-identity/types.ts" +import type { FindExternalIdentityDatabase } from "../../../repositories/external-identities/find-external-identity/types.ts" import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" import type { CreateUserInput } from "../../../repositories/users/create-user/types.ts" @@ -11,8 +12,18 @@ export interface IdentityCreationRepositories { findExternalIdentity(tuple: ExternalIdentityTuple): Promise<{ id: string; userId: string } | null> } +export type FindExternalIdentityForTransaction = ( + database: FindExternalIdentityDatabase, + tuple: ExternalIdentityTuple, +) => Promise<{ id: string; userId: string } | null> + export type IdentityCreationTransaction = ( operation: (repositories: IdentityCreationRepositories) => Promise, ) => Promise -export type IdentityDatabaseClient = Pick +export interface IdentityDatabaseClient { + $transaction( + operation: (transaction: Prisma.TransactionClient) => Promise, + options: { isolationLevel: Prisma.TransactionIsolationLevel }, + ): Promise +} diff --git a/hub/server/domains/identity/run-provider-revision-transaction/index.test.ts b/hub/server/domains/identity/run-provider-revision-transaction/index.test.ts new file mode 100644 index 00000000..4950898a --- /dev/null +++ b/hub/server/domains/identity/run-provider-revision-transaction/index.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" + +import { Prisma } from "../../../generated/prisma/client" +import { IdentityTransactionConflictError } from "../errors" +import type { ProviderRevisionDatabaseClient } from "./types" +import { runProviderRevisionTransaction } from "." + +function databaseError(code: string) { + return new Prisma.PrismaClientKnownRequestError("database failure", { + clientVersion: "7.10.1", + code, + }) +} + +function rejectingDatabase(error: Error): ProviderRevisionDatabaseClient { + return { + $transaction: () => Promise.reject(error), + } +} + +describe("runProviderRevisionTransaction()", () => { + it.each(["P2002", "P2034"])("translates retryable %s errors", async (code) => { + await expect( + runProviderRevisionTransaction(rejectingDatabase(databaseError(code)), () => + Promise.resolve("unreachable"), + ), + ).rejects.toBeInstanceOf(IdentityTransactionConflictError) + }) + + it("leaves foreign-key failures non-retryable", async () => { + const error = databaseError("P2003") + + await expect( + runProviderRevisionTransaction(rejectingDatabase(error), () => + Promise.resolve("unreachable"), + ), + ).rejects.toBe(error) + }) +}) diff --git a/hub/server/domains/identity/run-provider-revision-transaction/index.ts b/hub/server/domains/identity/run-provider-revision-transaction/index.ts index 7e55f5b8..5bd83a2a 100644 --- a/hub/server/domains/identity/run-provider-revision-transaction/index.ts +++ b/hub/server/domains/identity/run-provider-revision-transaction/index.ts @@ -5,11 +5,16 @@ import { findAuthenticationProvider } from "../../../repositories/authentication import { selectAuthenticationProviderRevision } from "../../../repositories/authentication-providers/select-authentication-provider-revision/index.ts" import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" import { IdentityTransactionConflictError } from "../errors.ts" -import type { ProviderRevisionDatabaseClient, ProviderRevisionRepositories } from "./types.ts" +import type { + FindAuthenticationProviderForTransaction, + ProviderRevisionDatabaseClient, + ProviderRevisionRepositories, +} from "./types.ts" export function runProviderRevisionTransaction( database: ProviderRevisionDatabaseClient, operation: (repositories: ProviderRevisionRepositories) => Promise, + findProvider: FindAuthenticationProviderForTransaction = findAuthenticationProvider, ): Promise { return database .$transaction( @@ -19,7 +24,7 @@ export function runProviderRevisionTransaction( createAuthenticationProviderRevision: (input) => createAuthenticationProviderRevision(transaction, input), createSecurityAudit: (input) => createSecurityAudit(transaction, input), - findAuthenticationProvider: (key) => findAuthenticationProvider(transaction, key), + findAuthenticationProvider: (key) => findProvider(transaction, key), selectAuthenticationProviderRevision: (input) => selectAuthenticationProviderRevision(transaction, input), }), diff --git a/hub/server/domains/identity/run-provider-revision-transaction/types.ts b/hub/server/domains/identity/run-provider-revision-transaction/types.ts index 1f34c992..7617606a 100644 --- a/hub/server/domains/identity/run-provider-revision-transaction/types.ts +++ b/hub/server/domains/identity/run-provider-revision-transaction/types.ts @@ -1,6 +1,7 @@ -import type { PrismaClient } from "../../../generated/prisma/client.ts" +import type { Prisma } from "../../../generated/prisma/client.ts" import type { CreateAuthenticationProviderRevisionInput } from "../../../repositories/authentication-providers/create-authentication-provider-revision/types.ts" import type { CreateAuthenticationProviderInput } from "../../../repositories/authentication-providers/create-authentication-provider/types.ts" +import type { FindAuthenticationProviderDatabase } from "../../../repositories/authentication-providers/find-authentication-provider/types.ts" import type { SelectAuthenticationProviderRevisionInput } from "../../../repositories/authentication-providers/select-authentication-provider-revision/types.ts" import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" @@ -17,6 +18,11 @@ export interface AuthenticationProviderRecord { selectedRevision: SelectedProviderRevisionRecord | null } +export type FindAuthenticationProviderForTransaction = ( + database: FindAuthenticationProviderDatabase, + key: string, +) => Promise + export interface ProviderRevisionRepositories { createAuthenticationProvider(input: CreateAuthenticationProviderInput): Promise createAuthenticationProviderRevision( @@ -33,4 +39,9 @@ export type ProviderRevisionTransaction = ( operation: (repositories: ProviderRevisionRepositories) => Promise, ) => Promise -export type ProviderRevisionDatabaseClient = Pick +export interface ProviderRevisionDatabaseClient { + $transaction( + operation: (transaction: Prisma.TransactionClient) => Promise, + options: { isolationLevel: Prisma.TransactionIsolationLevel }, + ): Promise +} diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts index 3aa8db20..eef075e1 100644 --- a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts @@ -7,7 +7,6 @@ import { prisma } from "../../../integrations/prisma" import { createAuthenticationProviderRevision } from "../../../repositories/authentication-providers/create-authentication-provider-revision" import { createAuthenticationProvider } from "../../../repositories/authentication-providers/create-authentication-provider" import { findAuthenticationProvider } from "../../../repositories/authentication-providers/find-authentication-provider" -import { selectAuthenticationProviderRevision } from "../../../repositories/authentication-providers/select-authentication-provider-revision" import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit" import { IdentityTransactionConflictError, @@ -67,40 +66,16 @@ function controlledConcurrentTransaction(): ProviderRevisionTransaction { release = resolve }) - return async (operation) => { - try { - return await prisma.$transaction( - (transaction) => - operation({ - createAuthenticationProvider: (value) => - createAuthenticationProvider(transaction, value), - createAuthenticationProviderRevision: (value) => - createAuthenticationProviderRevision(transaction, value), - createSecurityAudit: (value) => createSecurityAudit(transaction, value), - findAuthenticationProvider: async (key) => { - const provider = await findAuthenticationProvider(transaction, key) - if (arrivals < 2) { - arrivals += 1 - if (arrivals === 2) release?.() - await overlap - } - return provider - }, - selectAuthenticationProviderRevision: (value) => - selectAuthenticationProviderRevision(transaction, value), - }), - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, - ) - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - ["P2002", "P2034"].includes(error.code) - ) { - throw new IdentityTransactionConflictError({ cause: error }) + return (operation) => + runProviderRevisionTransaction(prisma, operation, async (database, key) => { + const provider = await findAuthenticationProvider(database, key) + if (arrivals < 2) { + arrivals += 1 + if (arrivals === 2) release?.() + await overlap } - throw error - } - } + return provider + }) } describe("validateAndPersistProviderRevision()", () => { diff --git a/hub/test/database/identity-foundation/index.db.test.ts b/hub/test/database/identity-foundation/index.db.test.ts index ab3021d6..e2dffc4c 100644 --- a/hub/test/database/identity-foundation/index.db.test.ts +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -141,6 +141,38 @@ describe("identity foundation migration", () => { ) }) + it("enforces the combined UTF-8 tuple index byte limit", async () => { + const userId = randomUUID() + const provider = "p" + const issuer = "界".repeat(500) + const subjectAtLimit = `${"界".repeat(165)}aa` + await createUser(prisma, { id: userId }) + + await expect( + prisma.externalIdentity.create({ + data: { + id: randomUUID(), + userId, + provider, + issuer, + subject: subjectAtLimit, + }, + }), + ).resolves.toMatchObject({ provider, issuer, subject: subjectAtLimit }) + await expect( + prisma.externalIdentity.create({ + data: { + id: randomUUID(), + userId, + provider, + issuer, + subject: `${subjectAtLimit}a`, + }, + }), + ).rejects.toThrow() + await expect(prisma.externalIdentity.count()).resolves.toBe(1) + }) + it("installs the indexes that protect tuple and revision concurrency", async () => { const indexes = await prisma.$queryRaw>` SELECT indexname From b44deabf1427617a30b15fa82df4c5a417b1e12b Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 10:53:34 -0700 Subject: [PATCH 7/8] fix(hub): harden identity edge cases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/server/domains/identity/errors.ts | 7 ++ .../index.test.ts | 32 ++++++++- .../parse-external-identity-tuple/index.ts | 20 +++++- .../index.db.test.ts | 49 +++++++++++++ .../index.db.test.ts | 71 +++++++++++++++++++ .../create-security-audit/index.test.ts | 22 ++++++ .../normalize-bounded-json/index.test.ts | 26 +++++++ 7 files changed, 225 insertions(+), 2 deletions(-) diff --git a/hub/server/domains/identity/errors.ts b/hub/server/domains/identity/errors.ts index 16743d65..40bb0d61 100644 --- a/hub/server/domains/identity/errors.ts +++ b/hub/server/domains/identity/errors.ts @@ -22,6 +22,13 @@ export class ExternalIdentityTupleTooLargeError extends Error { } } +export class ExternalIdentityTupleUnicodeError extends Error { + constructor() { + super("The external identity tuple contains invalid Unicode.") + this.name = "ExternalIdentityTupleUnicodeError" + } +} + export class ProviderRevisionConflictError extends Error { constructor(options?: ErrorOptions) { super("The provider revision conflicted with another provider configuration change.", options) diff --git a/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts b/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts index 00e7c4e3..35509473 100644 --- a/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { ExternalIdentityTupleTooLargeError } from "../errors" +import { ExternalIdentityTupleTooLargeError, ExternalIdentityTupleUnicodeError } from "../errors" import { externalIdentityTupleMaxBytes, parseExternalIdentityTuple } from "." const provider = "p" @@ -39,4 +39,34 @@ describe("parseExternalIdentityTuple()", () => { expect(parseExternalIdentityTuple(tuple)).toEqual(tuple) }) + + it.each([ + ["issuer NUL", { provider, issuer: "https://id.example.test\0", subject: "person" }], + ["subject NUL", { provider, issuer: "https://id.example.test", subject: "person\0" }], + ["issuer lone high surrogate", { provider, issuer: "issuer-\ud800", subject: "person" }], + ["issuer lone low surrogate", { provider, issuer: "issuer-\udc00", subject: "person" }], + ["subject lone high surrogate", { provider, issuer: "issuer", subject: "person-\ud800" }], + ["subject lone low surrogate", { provider, issuer: "issuer", subject: "person-\udc00" }], + ])("rejects %s with a typed safe error", (_name, tuple) => { + expect(() => parseExternalIdentityTuple(tuple)).toThrow(ExternalIdentityTupleUnicodeError) + }) + + it("rejects distinct malformed subjects that encode to the same replacement bytes", () => { + const high = "person-\ud800" + const low = "person-\udc00" + + expect(Buffer.from(high).equals(Buffer.from(low))).toBe(true) + expect(() => parseExternalIdentityTuple({ provider, issuer: "issuer", subject: high })).toThrow( + ExternalIdentityTupleUnicodeError, + ) + expect(() => parseExternalIdentityTuple({ provider, issuer: "issuer", subject: low })).toThrow( + ExternalIdentityTupleUnicodeError, + ) + }) + + it("allows valid surrogate pairs and non-BMP code points", () => { + const tuple = { provider, issuer: "issuer-\ud83d\ude80", subject: "person-\u{1f680}" } + + expect(parseExternalIdentityTuple(tuple)).toEqual(tuple) + }) }) diff --git a/hub/server/domains/identity/parse-external-identity-tuple/index.ts b/hub/server/domains/identity/parse-external-identity-tuple/index.ts index e05e7d27..8fd39b08 100644 --- a/hub/server/domains/identity/parse-external-identity-tuple/index.ts +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.ts @@ -1,7 +1,7 @@ import { z } from "zod" import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" -import { ExternalIdentityTupleTooLargeError } from "../errors.ts" +import { ExternalIdentityTupleTooLargeError, ExternalIdentityTupleUnicodeError } from "../errors.ts" export const externalIdentityTupleMaxBytes = 2_000 const externalIdentityTupleSchema = z @@ -12,6 +12,21 @@ const externalIdentityTupleSchema = z }) .strict() +function hasInvalidUnicode(value: string) { + if (value.includes("\0")) return true + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1) + if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) return true + index += 1 + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true + } + } + return false +} + export function parseExternalIdentityTuple(input: unknown): ExternalIdentityTuple { if ( typeof input !== "object" || @@ -21,6 +36,9 @@ export function parseExternalIdentityTuple(input: unknown): ExternalIdentityTupl throw new Error("External identity input must be a plain object.") } const tuple = externalIdentityTupleSchema.parse(input) + if (hasInvalidUnicode(tuple.issuer) || hasInvalidUnicode(tuple.subject)) { + throw new ExternalIdentityTupleUnicodeError() + } const bytes = Buffer.byteLength(tuple.provider, "utf8") + Buffer.byteLength(tuple.issuer, "utf8") + diff --git a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts index f7487ea4..c77256b8 100644 --- a/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts @@ -157,6 +157,55 @@ describe("resolveOrCreateExternalIdentity()", () => { await expect(prisma.securityAudit.count()).resolves.toBe(0) }) + it("rejects invalid Unicode before state or audit writes", async () => { + for (const input of [ + { ...tuple, issuer: `${tuple.issuer}\0` }, + { ...tuple, subject: `${tuple.subject}\0` }, + { ...tuple, issuer: "issuer-\ud800" }, + { ...tuple, issuer: "issuer-\udc00" }, + { ...tuple, subject: "subject-\ud800" }, + { ...tuple, subject: "subject-\udc00" }, + ]) { + await expect(resolveOrCreateExternalIdentity(input, dependencies())).rejects.toThrow( + "contains invalid Unicode", + ) + } + + await expect(prisma.user.count()).resolves.toBe(0) + await expect(prisma.externalIdentity.count()).resolves.toBe(0) + await expect(prisma.securityAudit.count()).resolves.toBe(0) + }) + + it("rolls back identity state when the final success audit insert fails", async () => { + const rawErrorMarker = "raw-success-audit-error-marker" + const base = dependencies() + async function runTransaction( + operation: (repositories: IdentityCreationRepositories) => Promise, + ): Promise { + return runIdentityCreationTransaction(prisma, (repositories) => + operation({ + ...repositories, + createSecurityAudit: () => Promise.reject(new Error(rawErrorMarker)), + }), + ) + } + + await expect( + resolveOrCreateExternalIdentity(tuple, { ...base, runTransaction }), + ).rejects.toThrow(rawErrorMarker) + await expect(prisma.user.count()).resolves.toBe(0) + await expect(prisma.externalIdentity.count()).resolves.toBe(0) + const audits = await prisma.securityAudit.findMany() + expect(audits).toHaveLength(1) + expect(audits[0]?.action).toBe("identity.external.create_failed") + expect(audits[0]?.context).toEqual({ + issuer: tuple.issuer, + provider: tuple.provider, + reason: "transaction_error", + }) + expect(JSON.stringify(audits)).not.toContain(rawErrorMarker) + }) + it("rolls back partial state before auditing an unexpected transaction failure", async () => { const base = dependencies() async function runTransaction( diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts index eef075e1..d7ac37ef 100644 --- a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts @@ -11,6 +11,7 @@ import { createSecurityAudit } from "../../../repositories/security-audits/creat import { IdentityTransactionConflictError, ProviderConfigurationValidationError, + ProviderRevisionConflictError, ProviderRevisionDriftError, ProviderRevisionGapError, ProviderRevisionRollbackError, @@ -304,6 +305,76 @@ describe("validateAndPersistProviderRevision()", () => { await expect(prisma.securityAudit.count({ where: { outcome: "FAILURE" } })).resolves.toBe(1) }) + it("rolls back revision selection when the final success audit insert fails", async () => { + const first = await validateAndPersistProviderRevision(input(1), dependencies()) + const rawErrorMarker = "raw-provider-success-audit-error-marker" + const base = dependencies() + async function runTransaction( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ): Promise { + return runProviderRevisionTransaction(prisma, (repositories) => + operation({ + ...repositories, + createSecurityAudit: () => Promise.reject(new Error(rawErrorMarker)), + }), + ) + } + + await expect( + validateAndPersistProviderRevision(input(2), { ...base, runTransaction }), + ).rejects.toThrow(rawErrorMarker) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1) + await expect( + prisma.authenticationProvider.findUnique({ + where: { id: first.providerId }, + include: { selectedRevision: true }, + }), + ).resolves.toMatchObject({ selectedRevision: { id: first.revisionId, revision: 1 } }) + const failureAudits = await prisma.securityAudit.findMany({ where: { outcome: "FAILURE" } }) + expect(failureAudits).toHaveLength(1) + expect(failureAudits[0]?.action).toBe("identity.provider_revision.persist_failed") + expect(failureAudits[0]?.context).toEqual({ + provider: "workforce", + reason: "transaction_error", + revision: 2, + }) + expect(JSON.stringify(failureAudits)).not.toContain(rawErrorMarker) + }) + + it("writes one terminal audit after the fixed provider retry budget is exhausted", async () => { + const first = await validateAndPersistProviderRevision(input(1), dependencies()) + const base = dependencies() + let attempts = 0 + function runTransaction(): Promise { + attempts += 1 + return Promise.reject(new IdentityTransactionConflictError()) + } + + await expect( + validateAndPersistProviderRevision(input(2), { ...base, runTransaction }), + ).rejects.toBeInstanceOf(ProviderRevisionConflictError) + expect(attempts).toBe(3) + await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1) + await expect( + prisma.authenticationProvider.findUnique({ + where: { id: first.providerId }, + include: { selectedRevision: true }, + }), + ).resolves.toMatchObject({ selectedRevision: { id: first.revisionId, revision: 1 } }) + await expect(prisma.securityAudit.findMany({ where: { outcome: "FAILURE" } })).resolves.toEqual( + [ + expect.objectContaining({ + action: "identity.provider_revision.persist_failed", + context: { + provider: "workforce", + reason: "retry_exhausted", + revision: 2, + }, + }), + ], + ) + }) + it("converges competing equal configuration to selected and unchanged", async () => { await validateAndPersistProviderRevision(input(1), dependencies()) const runTransaction = controlledConcurrentTransaction() diff --git a/hub/server/repositories/security-audits/create-security-audit/index.test.ts b/hub/server/repositories/security-audits/create-security-audit/index.test.ts index 3b90a244..07d74ba4 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.test.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.test.ts @@ -108,4 +108,26 @@ describe("createSecurityAudit()", () => { expect(() => createSecurityAudit({ securityAudit: { create } }, withUnexpected)).toThrow() expect(create).not.toHaveBeenCalled() }) + + it("allows safe metadata only through typed audit fields", async () => { + const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() + create.mockResolvedValue(undefined) + + await createSecurityAudit( + { securityAudit: { create } }, + { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + type: "external_identity_created", + externalIdentityId: "e8714e71-52a7-45e8-a639-99ec37f2d85f", + provider: "workforce", + issuer: "https://id.example.test", + }, + ) + + expect(create).toHaveBeenCalledOnce() + expect(create.mock.calls[0]?.[0].data.context).toEqual({ + issuer: "https://id.example.test", + provider: "workforce", + }) + }) }) diff --git a/hub/server/utils/normalize-bounded-json/index.test.ts b/hub/server/utils/normalize-bounded-json/index.test.ts index c72bf30c..bf176bae 100644 --- a/hub/server/utils/normalize-bounded-json/index.test.ts +++ b/hub/server/utils/normalize-bounded-json/index.test.ts @@ -106,4 +106,30 @@ describe("normalizeBoundedJson()", () => { normalizeBoundedJson({ nested: { deeper: { value: "too-deep" } } }, limits({ maxDepth: 2 })), ).toThrow("cannot exceed 2 nested levels") }) + + it("rejects representative credential keys and values", () => { + const privateKeyHeader = ["-----BEGIN PRIVATE", " KEY-----"].join("") + + expect(() => normalizeBoundedJson({ clientSecret: "opaque-value" }, limits())).toThrow( + "secret-bearing fields", + ) + expect(() => normalizeBoundedJson({ metadata: "Bearer opaque-value" }, limits())).toThrow( + "credential or key material", + ) + expect(() => normalizeBoundedJson({ certificate: privateKeyHeader }, limits())).toThrow( + "credential or key material", + ) + }) + + it("allows safe identity metadata without over-rejection", () => { + const metadata = { + tokenEndpoint: "https://id.example.test/token", + keyId: "signing-key-1", + publicKeyUrl: "https://id.example.test/keys", + issuer: "https://id.example.test", + label: "ordinary metadata", + } + + expect(normalizeBoundedJson(metadata, limits({ maxBytes: 1_000 }))).toEqual(metadata) + }) }) From 5c62ffc53398caa7ee5021c788a55ec7201ec338 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 30 Oct 2026 12:05:44 -0700 Subject: [PATCH 8/8] refactor(hub): align identity kernel boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/database.md | 4 +- .../migration.sql | 2 +- hub/schema/security-audit.prisma | 10 +- .../get-selected-provider-revision/index.ts | 9 +- .../index.test.ts | 11 ++ .../parse-external-identity-tuple/index.ts | 8 +- .../index.test.ts | 13 +- .../index.ts | 6 +- .../index.test.ts | 13 +- .../index.ts | 6 +- .../index.db.test.ts | 26 +++ .../index.ts | 16 +- .../create-security-audit-context/index.ts | 15 ++ .../create-security-audit/index.ts | 150 +----------------- .../get-security-audit-data/index.ts | 83 ++++++++++ .../parse-security-audit-event/index.test.ts | 30 ++++ .../parse-security-audit-event/index.ts | 71 +++++++++ .../index.test.ts | 18 +++ .../index.ts | 11 ++ .../utils/parse-provider-key/index.test.ts | 35 ++++ hub/server/utils/parse-provider-key/index.ts | 20 +++ .../identity-foundation/index.db.test.ts | 21 +++ .../create-known-request-error/index.ts | 8 + 23 files changed, 387 insertions(+), 199 deletions(-) create mode 100644 hub/server/repositories/security-audits/create-security-audit-context/index.ts create mode 100644 hub/server/repositories/security-audits/get-security-audit-data/index.ts create mode 100644 hub/server/repositories/security-audits/parse-security-audit-event/index.test.ts create mode 100644 hub/server/repositories/security-audits/parse-security-audit-event/index.ts create mode 100644 hub/server/utils/classify-prisma-transaction-error/index.test.ts create mode 100644 hub/server/utils/classify-prisma-transaction-error/index.ts create mode 100644 hub/server/utils/parse-provider-key/index.test.ts create mode 100644 hub/server/utils/parse-provider-key/index.ts create mode 100644 hub/test/prisma/create-known-request-error/index.ts diff --git a/hub/docs/database.md b/hub/docs/database.md index 4cc1f370..c64e948e 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,8 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. The schema contains the provider-neutral identity transaction foundation. Authentication provider revisions store a validated-configuration digest, not provider configuration, and every committed provider must select one of its own revisions. Hub sessions, memberships, invitations, source integrations, and product records are not part of this foundation. - -An external identity's provider, issuer, and subject share a 2,000-byte UTF-8 limit, including two separator bytes. Runtime parsing and the committed database constraint enforce the same conservative bound so the composite unique index remains below PostgreSQL's B-tree item limit. +Hub uses PostgreSQL and Prisma. [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. diff --git a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql index f6a2059f..f41b3752 100644 --- a/hub/schema/migrations/20261029203000_identity_foundation/migration.sql +++ b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql @@ -55,7 +55,7 @@ CREATE TABLE "authentication_provider_revisions" ( CREATE TABLE "security_audits" ( "id" UUID NOT NULL, "action" VARCHAR(100) NOT NULL, - "outcome" "SecurityAuditOutcome" NOT NULL, + "outcome" "SecurityAuditOutcome", "target_type" VARCHAR(100) NOT NULL, "target_id" VARCHAR(100), "context" JSONB NOT NULL, diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index 02785234..a0956d95 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -3,11 +3,11 @@ model SecurityAudit { id String @id @default(uuid()) @db.Uuid // Attribute columns - action String @db.VarChar(100) - outcome SecurityAuditOutcome - targetType String @map("target_type") @db.VarChar(100) - targetId String? @map("target_id") @db.VarChar(100) - context Json @db.JsonB + action String @db.VarChar(100) + outcome SecurityAuditOutcome? + targetType String @map("target_type") @db.VarChar(100) + targetId String? @map("target_id") @db.VarChar(100) + context Json @db.JsonB // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) diff --git a/hub/server/domains/identity/get-selected-provider-revision/index.ts b/hub/server/domains/identity/get-selected-provider-revision/index.ts index e30d73d3..1500c2e4 100644 --- a/hub/server/domains/identity/get-selected-provider-revision/index.ts +++ b/hub/server/domains/identity/get-selected-provider-revision/index.ts @@ -1,17 +1,12 @@ -import { z } from "zod" - +import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" import { ProviderRevisionInvariantError } from "../errors.ts" import type { GetSelectedProviderRevisionDependencies } from "./types.ts" -const providerKeySchema = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/) - export async function getSelectedProviderRevision( providerKey: string, dependencies: GetSelectedProviderRevisionDependencies, ) { - const provider = await dependencies.findAuthenticationProvider( - providerKeySchema.parse(providerKey), - ) + const provider = await dependencies.findAuthenticationProvider(parseProviderKey(providerKey)) if (!provider) return null if (!provider.selectedRevision) { throw new ProviderRevisionInvariantError( diff --git a/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts b/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts index 35509473..1aafebdd 100644 --- a/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts @@ -40,6 +40,17 @@ describe("parseExternalIdentityTuple()", () => { expect(parseExternalIdentityTuple(tuple)).toEqual(tuple) }) + it("uses the shared opaque provider key contract without changing case", () => { + const tuple = { + provider: "GitHub.App/V2", + issuer: "https://id.example.test", + subject: "person-123", + } + + expect(parseExternalIdentityTuple(tuple)).toEqual(tuple) + expect(() => parseExternalIdentityTuple({ ...tuple, provider: "provider key" })).toThrow() + }) + it.each([ ["issuer NUL", { provider, issuer: "https://id.example.test\0", subject: "person" }], ["subject NUL", { provider, issuer: "https://id.example.test", subject: "person\0" }], diff --git a/hub/server/domains/identity/parse-external-identity-tuple/index.ts b/hub/server/domains/identity/parse-external-identity-tuple/index.ts index 8fd39b08..3e0024cb 100644 --- a/hub/server/domains/identity/parse-external-identity-tuple/index.ts +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.ts @@ -1,12 +1,13 @@ import { z } from "zod" import type { ExternalIdentityTuple } from "../../../repositories/external-identities/find-external-identity/types.ts" +import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" import { ExternalIdentityTupleTooLargeError, ExternalIdentityTupleUnicodeError } from "../errors.ts" export const externalIdentityTupleMaxBytes = 2_000 const externalIdentityTupleSchema = z .object({ - provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + provider: z.unknown(), issuer: z.string().min(1).max(500), subject: z.string().min(1).max(500), }) @@ -36,16 +37,17 @@ export function parseExternalIdentityTuple(input: unknown): ExternalIdentityTupl throw new Error("External identity input must be a plain object.") } const tuple = externalIdentityTupleSchema.parse(input) + const provider = parseProviderKey(tuple.provider) if (hasInvalidUnicode(tuple.issuer) || hasInvalidUnicode(tuple.subject)) { throw new ExternalIdentityTupleUnicodeError() } const bytes = - Buffer.byteLength(tuple.provider, "utf8") + + Buffer.byteLength(provider, "utf8") + Buffer.byteLength(tuple.issuer, "utf8") + Buffer.byteLength(tuple.subject, "utf8") + 2 if (bytes > externalIdentityTupleMaxBytes) { throw new ExternalIdentityTupleTooLargeError() } - return tuple + return { provider, issuer: tuple.issuer, subject: tuple.subject } } diff --git a/hub/server/domains/identity/run-identity-creation-transaction/index.test.ts b/hub/server/domains/identity/run-identity-creation-transaction/index.test.ts index c3b1080b..eb28cb70 100644 --- a/hub/server/domains/identity/run-identity-creation-transaction/index.test.ts +++ b/hub/server/domains/identity/run-identity-creation-transaction/index.test.ts @@ -1,17 +1,10 @@ import { describe, expect, it } from "vitest" -import { Prisma } from "../../../generated/prisma/client" +import { createKnownRequestError } from "../../../../test/prisma/create-known-request-error" import { IdentityTransactionConflictError } from "../errors" import type { IdentityDatabaseClient } from "./types" import { runIdentityCreationTransaction } from "." -function databaseError(code: string) { - return new Prisma.PrismaClientKnownRequestError("database failure", { - clientVersion: "7.10.1", - code, - }) -} - function rejectingDatabase(error: Error): IdentityDatabaseClient { return { $transaction: () => Promise.reject(error), @@ -21,14 +14,14 @@ function rejectingDatabase(error: Error): IdentityDatabaseClient { describe("runIdentityCreationTransaction()", () => { it.each(["P2002", "P2034"])("translates retryable %s errors", async (code) => { await expect( - runIdentityCreationTransaction(rejectingDatabase(databaseError(code)), () => + runIdentityCreationTransaction(rejectingDatabase(createKnownRequestError(code)), () => Promise.resolve("unreachable"), ), ).rejects.toBeInstanceOf(IdentityTransactionConflictError) }) it("leaves foreign-key failures non-retryable", async () => { - const error = databaseError("P2003") + const error = createKnownRequestError("P2003") await expect( runIdentityCreationTransaction(rejectingDatabase(error), () => diff --git a/hub/server/domains/identity/run-identity-creation-transaction/index.ts b/hub/server/domains/identity/run-identity-creation-transaction/index.ts index 5f87a961..37859b2c 100644 --- a/hub/server/domains/identity/run-identity-creation-transaction/index.ts +++ b/hub/server/domains/identity/run-identity-creation-transaction/index.ts @@ -3,6 +3,7 @@ import { createExternalIdentity } from "../../../repositories/external-identitie import { findExternalIdentity } from "../../../repositories/external-identities/find-external-identity/index.ts" import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" import { createUser } from "../../../repositories/users/create-user/index.ts" +import { classifyPrismaTransactionError } from "../../../utils/classify-prisma-transaction-error/index.ts" import { IdentityTransactionConflictError } from "../errors.ts" import type { FindExternalIdentityForTransaction, @@ -27,10 +28,7 @@ export function runIdentityCreationTransaction( { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ) .catch((error: unknown) => { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - ["P2002", "P2034"].includes(error.code) - ) { + if (classifyPrismaTransactionError(error) === "retryable") { throw new IdentityTransactionConflictError({ cause: error }) } throw error diff --git a/hub/server/domains/identity/run-provider-revision-transaction/index.test.ts b/hub/server/domains/identity/run-provider-revision-transaction/index.test.ts index 4950898a..2bc15a7a 100644 --- a/hub/server/domains/identity/run-provider-revision-transaction/index.test.ts +++ b/hub/server/domains/identity/run-provider-revision-transaction/index.test.ts @@ -1,17 +1,10 @@ import { describe, expect, it } from "vitest" -import { Prisma } from "../../../generated/prisma/client" +import { createKnownRequestError } from "../../../../test/prisma/create-known-request-error" import { IdentityTransactionConflictError } from "../errors" import type { ProviderRevisionDatabaseClient } from "./types" import { runProviderRevisionTransaction } from "." -function databaseError(code: string) { - return new Prisma.PrismaClientKnownRequestError("database failure", { - clientVersion: "7.10.1", - code, - }) -} - function rejectingDatabase(error: Error): ProviderRevisionDatabaseClient { return { $transaction: () => Promise.reject(error), @@ -21,14 +14,14 @@ function rejectingDatabase(error: Error): ProviderRevisionDatabaseClient { describe("runProviderRevisionTransaction()", () => { it.each(["P2002", "P2034"])("translates retryable %s errors", async (code) => { await expect( - runProviderRevisionTransaction(rejectingDatabase(databaseError(code)), () => + runProviderRevisionTransaction(rejectingDatabase(createKnownRequestError(code)), () => Promise.resolve("unreachable"), ), ).rejects.toBeInstanceOf(IdentityTransactionConflictError) }) it("leaves foreign-key failures non-retryable", async () => { - const error = databaseError("P2003") + const error = createKnownRequestError("P2003") await expect( runProviderRevisionTransaction(rejectingDatabase(error), () => diff --git a/hub/server/domains/identity/run-provider-revision-transaction/index.ts b/hub/server/domains/identity/run-provider-revision-transaction/index.ts index 5bd83a2a..0c4f8f3d 100644 --- a/hub/server/domains/identity/run-provider-revision-transaction/index.ts +++ b/hub/server/domains/identity/run-provider-revision-transaction/index.ts @@ -4,6 +4,7 @@ import { createAuthenticationProvider } from "../../../repositories/authenticati import { findAuthenticationProvider } from "../../../repositories/authentication-providers/find-authentication-provider/index.ts" import { selectAuthenticationProviderRevision } from "../../../repositories/authentication-providers/select-authentication-provider-revision/index.ts" import { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit/index.ts" +import { classifyPrismaTransactionError } from "../../../utils/classify-prisma-transaction-error/index.ts" import { IdentityTransactionConflictError } from "../errors.ts" import type { FindAuthenticationProviderForTransaction, @@ -31,10 +32,7 @@ export function runProviderRevisionTransaction( { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ) .catch((error: unknown) => { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - ["P2002", "P2034"].includes(error.code) - ) { + if (classifyPrismaTransactionError(error) === "retryable") { throw new IdentityTransactionConflictError({ cause: error }) } throw error diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts index d7ac37ef..241fe06f 100644 --- a/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts @@ -86,6 +86,7 @@ describe("validateAndPersistProviderRevision()", () => { order.push("validate") return validateConfiguration(candidate) }) + const base = dependencies(validator) async function runTransaction( operation: (repositories: ProviderRevisionRepositories) => Promise, @@ -112,6 +113,31 @@ describe("validateAndPersistProviderRevision()", () => { expect(Object.keys(revision ?? {})).not.toContain("settings") }) + it("uses the same opaque provider key for provider state and typed audits", async () => { + const providerKey = "GitHub.App/V2" + + const result = await validateAndPersistProviderRevision( + { ...input(1), providerKey }, + dependencies(), + ) + + await expect( + prisma.authenticationProvider.findUnique({ where: { id: result.providerId } }), + ).resolves.toMatchObject({ key: providerKey }) + await expect( + prisma.securityAudit.findFirst({ + where: { action: "identity.provider_revision.selected" }, + }), + ).resolves.toMatchObject({ context: { provider: providerKey, revision: 1 } }) + await expect( + validateAndPersistProviderRevision( + { ...input(2), providerKey: "provider key" }, + dependencies(), + ), + ).rejects.toThrow() + await expect(prisma.authenticationProvider.count()).resolves.toBe(1) + }) + it("persists no validated configuration value beyond its digest", async () => { const marker = "opaque-public-configuration-marker" diff --git a/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts b/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts index 6e4dd74f..e75355cc 100644 --- a/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto" import { z } from "zod" +import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" import { IdentityTransactionConflictError, ProviderConfigurationValidationError, @@ -20,7 +21,7 @@ import type { const providerRevisionSchema = z .object({ - providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), + providerKey: z.unknown(), revision: z.number().int().positive(), configuration: z.unknown(), }) @@ -46,6 +47,7 @@ export async function validateAndPersistProviderRevision( throw new Error("Provider revision input must be a plain object.") } const parsed = providerRevisionSchema.parse(input) + const providerKey = parseProviderKey(parsed.providerKey) let canonicalPublicConfiguration: string try { const validated = await dependencies.validateConfiguration(input.configuration) @@ -65,7 +67,7 @@ export async function validateAndPersistProviderRevision( await dependencies.writeAudit({ id: dependencies.createId(), type: "provider_revision_validation_failed", - providerKey: parsed.providerKey, + providerKey, revision: parsed.revision, }) } catch (auditError) { @@ -85,7 +87,7 @@ export async function validateAndPersistProviderRevision( return await runWithBoundedRetries( () => dependencies.runTransaction(async (repositories) => { - const current = await getSelectedProviderRevision(parsed.providerKey, repositories) + const current = await getSelectedProviderRevision(providerKey, repositories) let providerId: string if (current) { @@ -108,7 +110,7 @@ export async function validateAndPersistProviderRevision( providerId = dependencies.createId() await repositories.createAuthenticationProvider({ id: providerId, - key: parsed.providerKey, + key: providerKey, }) } @@ -124,7 +126,7 @@ export async function validateAndPersistProviderRevision( id: dependencies.createId(), type: "provider_revision_selected", providerId, - providerKey: parsed.providerKey, + providerKey, revision: parsed.revision, }) @@ -142,14 +144,14 @@ export async function validateAndPersistProviderRevision( ? { id: dependencies.createId(), type: "provider_revision_denied", - providerKey: parsed.providerKey, + providerKey, revision: parsed.revision, reason, } : { id: dependencies.createId(), type: "provider_revision_persist_failed", - providerKey: parsed.providerKey, + providerKey, revision: parsed.revision, reason: conflict ? "retry_exhausted" : "transaction_error", }, diff --git a/hub/server/repositories/security-audits/create-security-audit-context/index.ts b/hub/server/repositories/security-audits/create-security-audit-context/index.ts new file mode 100644 index 00000000..6ddbfa80 --- /dev/null +++ b/hub/server/repositories/security-audits/create-security-audit-context/index.ts @@ -0,0 +1,15 @@ +import { normalizeBoundedJson } from "../../../utils/normalize-bounded-json/index.ts" + +export function createSecurityAuditContext( + value: Record, +) { + return normalizeBoundedJson(value, { + maxArrayItems: 0, + maxBytes: 4_096, + maxDepth: 1, + maxEntries: 8, + maxKeyLength: 100, + maxObjectFields: 8, + maxStringLength: 500, + }) +} diff --git a/hub/server/repositories/security-audits/create-security-audit/index.ts b/hub/server/repositories/security-audits/create-security-audit/index.ts index f2435e92..e402be7c 100644 --- a/hub/server/repositories/security-audits/create-security-audit/index.ts +++ b/hub/server/repositories/security-audits/create-security-audit/index.ts @@ -1,153 +1,13 @@ -import type { Prisma } from "../../../generated/prisma/client.ts" -import { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" -import { normalizeBoundedJson } from "../../../utils/normalize-bounded-json/index.ts" -import { z } from "zod" +import { getSecurityAuditData } from "../get-security-audit-data/index.ts" +import { parseSecurityAuditEvent } from "../parse-security-audit-event/index.ts" import type { CreateSecurityAuditDatabase, CreateSecurityAuditEvent } from "./types.ts" -const eventSchema = z.discriminatedUnion("type", [ - z - .object({ - id: z.uuid(), - type: z.literal("external_identity_created"), - externalIdentityId: z.uuid(), - issuer: z.string().min(1).max(500), - provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), - }) - .strict(), - z - .object({ - id: z.uuid(), - type: z.literal("external_identity_create_failed"), - issuer: z.string().min(1).max(500), - provider: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), - reason: z.enum(["retry_exhausted", "transaction_error"]), - }) - .strict(), - z - .object({ - id: z.uuid(), - type: z.literal("provider_revision_validation_failed"), - providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), - revision: z.number().int().positive(), - }) - .strict(), - z - .object({ - id: z.uuid(), - type: z.literal("provider_revision_selected"), - providerId: z.uuid(), - providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), - revision: z.number().int().positive(), - }) - .strict(), - z - .object({ - id: z.uuid(), - type: z.literal("provider_revision_denied"), - providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), - reason: z.enum(["invariant", "revision_gap", "rollback", "same_revision_drift"]), - revision: z.number().int().positive(), - }) - .strict(), - z - .object({ - id: z.uuid(), - type: z.literal("provider_revision_persist_failed"), - providerKey: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/), - reason: z.enum(["retry_exhausted", "transaction_error"]), - revision: z.number().int().positive(), - }) - .strict(), -]) - -function context(value: Record) { - return normalizeBoundedJson(value, { - maxArrayItems: 0, - maxBytes: 4_096, - maxDepth: 1, - maxEntries: 8, - maxKeyLength: 100, - maxObjectFields: 8, - maxStringLength: 500, - }) -} - -function dataForEvent(event: CreateSecurityAuditEvent): Prisma.SecurityAuditCreateInput { - switch (event.type) { - case "external_identity_created": - return { - id: event.id, - action: "identity.external.created", - outcome: SecurityAuditOutcome.SUCCESS, - targetType: "external_identity", - targetId: event.externalIdentityId, - context: context({ provider: event.provider, issuer: event.issuer }), - } - case "external_identity_create_failed": - return { - id: event.id, - action: "identity.external.create_failed", - outcome: SecurityAuditOutcome.FAILURE, - targetType: "external_identity", - context: context({ - provider: event.provider, - issuer: event.issuer, - reason: event.reason, - }), - } - case "provider_revision_selected": - return { - id: event.id, - action: "identity.provider_revision.selected", - outcome: SecurityAuditOutcome.SUCCESS, - targetType: "authentication_provider", - targetId: event.providerId, - context: context({ provider: event.providerKey, revision: event.revision }), - } - case "provider_revision_validation_failed": - return { - id: event.id, - action: "identity.provider_revision.validation_failed", - outcome: SecurityAuditOutcome.FAILURE, - targetType: "authentication_provider", - context: context({ - provider: event.providerKey, - revision: event.revision, - reason: "validation_failed", - }), - } - case "provider_revision_denied": - return { - id: event.id, - action: "identity.provider_revision.denied", - outcome: SecurityAuditOutcome.DENIED, - targetType: "authentication_provider", - context: context({ - provider: event.providerKey, - revision: event.revision, - reason: event.reason, - }), - } - case "provider_revision_persist_failed": - return { - id: event.id, - action: "identity.provider_revision.persist_failed", - outcome: SecurityAuditOutcome.FAILURE, - targetType: "authentication_provider", - context: context({ - provider: event.providerKey, - revision: event.revision, - reason: event.reason, - }), - } - } -} - export function createSecurityAudit( database: CreateSecurityAuditDatabase, event: CreateSecurityAuditEvent, ): Promise export function createSecurityAudit(database: CreateSecurityAuditDatabase, input: unknown) { - const event: CreateSecurityAuditEvent = eventSchema.parse(input) - return database.securityAudit.create({ data: dataForEvent(event) }) + return database.securityAudit.create({ + data: getSecurityAuditData(parseSecurityAuditEvent(input)), + }) } diff --git a/hub/server/repositories/security-audits/get-security-audit-data/index.ts b/hub/server/repositories/security-audits/get-security-audit-data/index.ts new file mode 100644 index 00000000..4e9a8391 --- /dev/null +++ b/hub/server/repositories/security-audits/get-security-audit-data/index.ts @@ -0,0 +1,83 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import { SecurityAuditOutcome } from "../../../generated/prisma/enums.ts" +import { createSecurityAuditContext } from "../create-security-audit-context/index.ts" +import type { CreateSecurityAuditEvent } from "../create-security-audit/types.ts" + +export function getSecurityAuditData( + event: CreateSecurityAuditEvent, +): Prisma.SecurityAuditCreateInput { + switch (event.type) { + case "external_identity_created": + return { + id: event.id, + action: "identity.external.created", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "external_identity", + targetId: event.externalIdentityId, + context: createSecurityAuditContext({ + provider: event.provider, + issuer: event.issuer, + }), + } + case "external_identity_create_failed": + return { + id: event.id, + action: "identity.external.create_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "external_identity", + context: createSecurityAuditContext({ + provider: event.provider, + issuer: event.issuer, + reason: event.reason, + }), + } + case "provider_revision_selected": + return { + id: event.id, + action: "identity.provider_revision.selected", + outcome: SecurityAuditOutcome.SUCCESS, + targetType: "authentication_provider", + targetId: event.providerId, + context: createSecurityAuditContext({ + provider: event.providerKey, + revision: event.revision, + }), + } + case "provider_revision_validation_failed": + return { + id: event.id, + action: "identity.provider_revision.validation_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "authentication_provider", + context: createSecurityAuditContext({ + provider: event.providerKey, + revision: event.revision, + reason: "validation_failed", + }), + } + case "provider_revision_denied": + return { + id: event.id, + action: "identity.provider_revision.denied", + outcome: SecurityAuditOutcome.DENIED, + targetType: "authentication_provider", + context: createSecurityAuditContext({ + provider: event.providerKey, + revision: event.revision, + reason: event.reason, + }), + } + case "provider_revision_persist_failed": + return { + id: event.id, + action: "identity.provider_revision.persist_failed", + outcome: SecurityAuditOutcome.FAILURE, + targetType: "authentication_provider", + context: createSecurityAuditContext({ + provider: event.providerKey, + revision: event.revision, + reason: event.reason, + }), + } + } +} diff --git a/hub/server/repositories/security-audits/parse-security-audit-event/index.test.ts b/hub/server/repositories/security-audits/parse-security-audit-event/index.test.ts new file mode 100644 index 00000000..a4b020b5 --- /dev/null +++ b/hub/server/repositories/security-audits/parse-security-audit-event/index.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" + +import { parseSecurityAuditEvent } from "." + +describe("parseSecurityAuditEvent()", () => { + it("uses the shared opaque provider key contract", () => { + const event = { + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + type: "provider_revision_selected", + providerId: "df75faa4-ed4c-44ed-8db0-443cc3b426ed", + providerKey: "GitHub.App/V2", + revision: 1, + } + + expect(parseSecurityAuditEvent(event)).toEqual(event) + expect(() => parseSecurityAuditEvent({ ...event, providerKey: "provider key" })).toThrow() + }) + + it("rejects unknown event fields", () => { + expect(() => + parseSecurityAuditEvent({ + id: "5dd31031-6f1c-4c16-87c2-aee5342145cc", + type: "provider_revision_validation_failed", + providerKey: "workforce", + revision: 1, + context: {}, + }), + ).toThrow() + }) +}) diff --git a/hub/server/repositories/security-audits/parse-security-audit-event/index.ts b/hub/server/repositories/security-audits/parse-security-audit-event/index.ts new file mode 100644 index 00000000..3a346fd0 --- /dev/null +++ b/hub/server/repositories/security-audits/parse-security-audit-event/index.ts @@ -0,0 +1,71 @@ +import { z } from "zod" + +import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" +import type { CreateSecurityAuditEvent } from "../create-security-audit/types.ts" + +const eventSchema = z.discriminatedUnion("type", [ + z + .object({ + id: z.uuid(), + type: z.literal("external_identity_created"), + externalIdentityId: z.uuid(), + issuer: z.string().min(1).max(500), + provider: z.unknown(), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("external_identity_create_failed"), + issuer: z.string().min(1).max(500), + provider: z.unknown(), + reason: z.enum(["retry_exhausted", "transaction_error"]), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("provider_revision_validation_failed"), + providerKey: z.unknown(), + revision: z.number().int().positive(), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("provider_revision_selected"), + providerId: z.uuid(), + providerKey: z.unknown(), + revision: z.number().int().positive(), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("provider_revision_denied"), + providerKey: z.unknown(), + reason: z.enum(["invariant", "revision_gap", "rollback", "same_revision_drift"]), + revision: z.number().int().positive(), + }) + .strict(), + z + .object({ + id: z.uuid(), + type: z.literal("provider_revision_persist_failed"), + providerKey: z.unknown(), + reason: z.enum(["retry_exhausted", "transaction_error"]), + revision: z.number().int().positive(), + }) + .strict(), +]) + +export function parseSecurityAuditEvent(input: unknown): CreateSecurityAuditEvent { + const event = eventSchema.parse(input) + switch (event.type) { + case "external_identity_created": + case "external_identity_create_failed": + return { ...event, provider: parseProviderKey(event.provider) } + default: + return { ...event, providerKey: parseProviderKey(event.providerKey) } + } +} diff --git a/hub/server/utils/classify-prisma-transaction-error/index.test.ts b/hub/server/utils/classify-prisma-transaction-error/index.test.ts new file mode 100644 index 00000000..e03ed54b --- /dev/null +++ b/hub/server/utils/classify-prisma-transaction-error/index.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest" + +import { createKnownRequestError } from "../../../test/prisma/create-known-request-error" +import { classifyPrismaTransactionError } from "." + +describe("classifyPrismaTransactionError()", () => { + it.each(["P2002", "P2034"])("classifies %s as retryable", (code) => { + expect(classifyPrismaTransactionError(createKnownRequestError(code))).toBe("retryable") + }) + + it("classifies other known errors as terminal", () => { + expect(classifyPrismaTransactionError(createKnownRequestError("P2003"))).toBe("terminal") + }) + + it("classifies unrelated errors as unknown", () => { + expect(classifyPrismaTransactionError(new Error("unrelated"))).toBe("unknown") + }) +}) diff --git a/hub/server/utils/classify-prisma-transaction-error/index.ts b/hub/server/utils/classify-prisma-transaction-error/index.ts new file mode 100644 index 00000000..32b1d49c --- /dev/null +++ b/hub/server/utils/classify-prisma-transaction-error/index.ts @@ -0,0 +1,11 @@ +import { Prisma } from "../../generated/prisma/client.ts" + +export type PrismaTransactionErrorClassification = "retryable" | "terminal" | "unknown" + +export function classifyPrismaTransactionError( + error: unknown, +): PrismaTransactionErrorClassification { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return "unknown" + if (["P2002", "P2034"].includes(error.code)) return "retryable" + return "terminal" +} diff --git a/hub/server/utils/parse-provider-key/index.test.ts b/hub/server/utils/parse-provider-key/index.test.ts new file mode 100644 index 00000000..7fcb3119 --- /dev/null +++ b/hub/server/utils/parse-provider-key/index.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" + +import { parseProviderKey, providerKeyMaxBytes, providerKeyMaxCharacters } from "." + +describe("parseProviderKey()", () => { + it.each(["GitHub.App/V2", "Entra:Tenant", "custom+adapter@example", "提供者"])( + "preserves the opaque case-sensitive key %s", + (key) => { + expect(parseProviderKey(key)).toBe(key) + }, + ) + + it("accepts exact character and UTF-8 byte boundaries", () => { + expect(parseProviderKey("a".repeat(providerKeyMaxCharacters))).toHaveLength( + providerKeyMaxCharacters, + ) + expect(parseProviderKey("界".repeat(100))).toHaveLength(100) + expect(Buffer.byteLength(parseProviderKey("𐍈".repeat(100)))).toBe(providerKeyMaxBytes) + }) + + it("rejects empty, overlong, control, malformed, and whitespace keys", () => { + for (const key of [ + "", + "a".repeat(101), + "𐍈".repeat(101), + "provider\0key", + "provider\nkey", + "provider key", + "provider\u00a0key", + "provider\ud800", + ]) { + expect(() => parseProviderKey(key)).toThrow() + } + }) +}) diff --git a/hub/server/utils/parse-provider-key/index.ts b/hub/server/utils/parse-provider-key/index.ts new file mode 100644 index 00000000..ca46d940 --- /dev/null +++ b/hub/server/utils/parse-provider-key/index.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +export const providerKeyMaxCharacters = 100 +export const providerKeyMaxBytes = 400 + +const providerKeySchema = z.string().min(1) + +export function parseProviderKey(input: unknown): string { + const key = providerKeySchema.parse(input) + if ( + [...key].length > providerKeyMaxCharacters || + Buffer.byteLength(key, "utf8") > providerKeyMaxBytes + ) { + throw new Error("Provider key exceeds its bounded length.") + } + if (/[\p{C}\p{Z}\s]/u.test(key)) { + throw new Error("Provider key cannot contain control or whitespace characters.") + } + return key +} diff --git a/hub/test/database/identity-foundation/index.db.test.ts b/hub/test/database/identity-foundation/index.db.test.ts index e2dffc4c..17631e37 100644 --- a/hub/test/database/identity-foundation/index.db.test.ts +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -356,4 +356,25 @@ describe("identity foundation migration", () => { ).rejects.toThrow() await expect(prisma.securityAudit.count()).resolves.toBe(0) }) + + it("allows informational immutable audits without an outcome", async () => { + const auditId = randomUUID() + + await expect( + prisma.securityAudit.create({ + data: { + id: auditId, + action: "identity.kernel.observed", + targetType: "identity_kernel", + context: { detail: "informational" }, + }, + }), + ).resolves.toMatchObject({ id: auditId, outcome: null }) + await expect( + prisma.securityAudit.update({ + where: { id: auditId }, + data: { outcome: "SUCCESS" }, + }), + ).rejects.toThrow("Security audit records are immutable") + }) }) diff --git a/hub/test/prisma/create-known-request-error/index.ts b/hub/test/prisma/create-known-request-error/index.ts new file mode 100644 index 00000000..c1667b9c --- /dev/null +++ b/hub/test/prisma/create-known-request-error/index.ts @@ -0,0 +1,8 @@ +import { Prisma } from "../../../server/generated/prisma/client.ts" + +export function createKnownRequestError(code: string) { + return new Prisma.PrismaClientKnownRequestError("database failure", { + clientVersion: "test", + code, + }) +}