diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 65dad84c..123835c6 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,12 @@ 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 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. 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. + ## Frontend ```mermaid diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 60976740..c1bb39d0 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -40,3 +40,11 @@ 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. 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. + +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/docs/database.md b/hub/docs/database.md index 1663e7f1..c64e948e 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. [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-revision.prisma b/hub/schema/authentication-provider-revision.prisma new file mode 100644 index 00000000..7095b0c4 --- /dev/null +++ b/hub/schema/authentication-provider-revision.prisma @@ -0,0 +1,21 @@ +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) + + // 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 new file mode 100644 index 00000000..10a5cbda --- /dev/null +++ b/hub/schema/authentication-provider.prisma @@ -0,0 +1,18 @@ +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") +} 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..f41b3752 --- /dev/null +++ b/hub/schema/migrations/20261029203000_identity_foundation/migration.sql @@ -0,0 +1,177 @@ +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 + ), + CONSTRAINT "external_identities_tuple_index_bytes" CHECK ( + octet_length("provider") + octet_length("issuer") + octet_length("subject") + 2 <= 2000 + ) +); + +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, + "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}$' + ) +); + +CREATE TABLE "security_audits" ( + "id" UUID NOT NULL, + "action" VARCHAR(100) NOT NULL, + "outcome" "SecurityAuditOutcome", + "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_changes() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'External identity records are immutable.'; +END; +$$; + +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 + RAISE EXCEPTION 'Authentication provider revisions are immutable.'; +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 $$ +DECLARE + selected_revision UUID; +BEGIN + 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" = selected_revision + 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..a0956d95 --- /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/errors.ts b/hub/server/domains/identity/errors.ts new file mode 100644 index 00000000..40bb0d61 --- /dev/null +++ b/hub/server/domains/identity/errors.ts @@ -0,0 +1,72 @@ +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 ExternalIdentityTupleTooLargeError extends Error { + constructor() { + super("The external identity tuple exceeds the 2000-byte UTF-8 index limit.") + this.name = "ExternalIdentityTupleTooLargeError" + } +} + +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) + this.name = "ProviderRevisionConflictError" + } +} + +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) + 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 validated configuration.") + 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..1500c2e4 --- /dev/null +++ b/hub/server/domains/identity/get-selected-provider-revision/index.ts @@ -0,0 +1,22 @@ +import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" +import { ProviderRevisionInvariantError } from "../errors.ts" +import type { GetSelectedProviderRevisionDependencies } from "./types.ts" + +export async function getSelectedProviderRevision( + providerKey: string, + dependencies: GetSelectedProviderRevisionDependencies, +) { + const provider = await dependencies.findAuthenticationProvider(parseProviderKey(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/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..1aafebdd --- /dev/null +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" + +import { ExternalIdentityTupleTooLargeError, ExternalIdentityTupleUnicodeError } 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) + }) + + 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" }], + ["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 new file mode 100644 index 00000000..3e0024cb --- /dev/null +++ b/hub/server/domains/identity/parse-external-identity-tuple/index.ts @@ -0,0 +1,53 @@ +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.unknown(), + issuer: z.string().min(1).max(500), + subject: z.string().min(1).max(500), + }) + .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" || + input === null || + Object.getPrototypeOf(input) !== Object.prototype + ) { + 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(provider, "utf8") + + Buffer.byteLength(tuple.issuer, "utf8") + + Buffer.byteLength(tuple.subject, "utf8") + + 2 + if (bytes > externalIdentityTupleMaxBytes) { + throw new ExternalIdentityTupleTooLargeError() + } + return { provider, issuer: tuple.issuer, subject: tuple.subject } +} 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..0d4fb2f1 --- /dev/null +++ b/hub/server/domains/identity/resolve-external-identity/index.ts @@ -0,0 +1,14 @@ +import { parseExternalIdentityTuple } from "../parse-external-identity-tuple/index.ts" +import type { ResolveExternalIdentityDependencies, ResolveExternalIdentityResult } from "./types.ts" + +export async function resolveExternalIdentity( + input: unknown, + dependencies: ResolveExternalIdentityDependencies, +): Promise { + const tuple = parseExternalIdentityTuple(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..c77256b8 --- /dev/null +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.db.test.ts @@ -0,0 +1,262 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it, vi } from "vitest" + +import { Prisma } from "../../../generated/prisma/client" +import { prisma } from "../../../integrations/prisma" +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 } 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 (operation) => + runIdentityCreationTransaction(prisma, operation, async (database, input) => { + const identity = await findExternalIdentity(database, input) + if (!identity) { + arrivals += 1 + if (arrivals === 2) release?.() + await overlap + } + return identity + }) +} + +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("retries generated user ID collisions before creating the identity", async () => { + const collidedUserId = randomUUID() + await createUser(prisma, { id: collidedUserId }) + 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 createId = vi.fn(() => collidedUserId) + + await expect( + 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({ + action: "identity.external.create_failed", + outcome: "FAILURE", + context: { + provider: tuple.provider, + issuer: tuple.issuer, + 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("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( + 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.all([first, second]) + + 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.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 new file mode 100644 index 00000000..950b0985 --- /dev/null +++ b/hub/server/domains/identity/resolve-or-create-external-identity/index.ts @@ -0,0 +1,76 @@ +import { ExternalIdentityCollisionError, IdentityTransactionConflictError } from "../errors.ts" +import { parseExternalIdentityTuple } from "../parse-external-identity-tuple/index.ts" +import { runWithBoundedRetries } from "../run-with-bounded-retries/index.ts" +import type { + ResolveOrCreateExternalIdentityDependencies, + ResolveOrCreateExternalIdentityInput, + ResolveOrCreateExternalIdentityResult, +} from "./types.ts" + +export async function resolveOrCreateExternalIdentity( + input: ResolveOrCreateExternalIdentityInput, + dependencies: ResolveOrCreateExternalIdentityDependencies, +): Promise { + const tuple = parseExternalIdentityTuple(input) + + try { + return await runWithBoundedRetries( + () => + dependencies.runTransaction(async (repositories) => { + const existing = await repositories.findExternalIdentity(tuple) + if (existing) { + return { + status: "found", + externalIdentityId: existing.id, + userId: existing.userId, + } + } + + 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 } + }), + (error) => error instanceof IdentityTransactionConflictError, + ) + } catch (error) { + const failure = + error instanceof IdentityTransactionConflictError + ? new ExternalIdentityCollisionError({ cause: error }) + : error + try { + await dependencies.writeAudit({ + id: dependencies.createId(), + type: "external_identity_create_failed", + provider: tuple.provider, + issuer: tuple.issuer, + reason: + error instanceof IdentityTransactionConflictError + ? "retry_exhausted" + : "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..27cf4586 --- /dev/null +++ b/hub/server/domains/identity/resolve-or-create-external-identity/types.ts @@ -0,0 +1,16 @@ +import type { CreateSecurityAuditEvent } 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: CreateSecurityAuditEvent): Promise +} + +export interface ResolveOrCreateExternalIdentityResult { + externalIdentityId: string + status: "created" | "found" + userId: string +} + +export type ResolveOrCreateExternalIdentityInput = unknown 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..eb28cb70 --- /dev/null +++ b/hub/server/domains/identity/run-identity-creation-transaction/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest" + +import { createKnownRequestError } from "../../../../test/prisma/create-known-request-error" +import { IdentityTransactionConflictError } from "../errors" +import type { IdentityDatabaseClient } from "./types" +import { runIdentityCreationTransaction } from "." + +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(createKnownRequestError(code)), () => + Promise.resolve("unreachable"), + ), + ).rejects.toBeInstanceOf(IdentityTransactionConflictError) + }) + + it("leaves foreign-key failures non-retryable", async () => { + const error = createKnownRequestError("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 new file mode 100644 index 00000000..37859b2c --- /dev/null +++ b/hub/server/domains/identity/run-identity-creation-transaction/index.ts @@ -0,0 +1,36 @@ +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 { classifyPrismaTransactionError } from "../../../utils/classify-prisma-transaction-error/index.ts" +import { IdentityTransactionConflictError } from "../errors.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( + (transaction) => + operation({ + createExternalIdentity: (input) => createExternalIdentity(transaction, input), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + createUser: (input) => createUser(transaction, input), + findExternalIdentity: (tuple) => findIdentity(transaction, tuple), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + .catch((error: unknown) => { + if (classifyPrismaTransactionError(error) === "retryable") { + 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..892d14a5 --- /dev/null +++ b/hub/server/domains/identity/run-identity-creation-transaction/types.ts @@ -0,0 +1,29 @@ +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" + +export interface IdentityCreationRepositories { + createExternalIdentity(input: CreateExternalIdentityInput): Promise + createSecurityAudit(input: CreateSecurityAuditEvent): Promise + createUser(input: CreateUserInput): Promise + 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 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..2bc15a7a --- /dev/null +++ b/hub/server/domains/identity/run-provider-revision-transaction/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest" + +import { createKnownRequestError } from "../../../../test/prisma/create-known-request-error" +import { IdentityTransactionConflictError } from "../errors" +import type { ProviderRevisionDatabaseClient } from "./types" +import { runProviderRevisionTransaction } from "." + +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(createKnownRequestError(code)), () => + Promise.resolve("unreachable"), + ), + ).rejects.toBeInstanceOf(IdentityTransactionConflictError) + }) + + it("leaves foreign-key failures non-retryable", async () => { + const error = createKnownRequestError("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 new file mode 100644 index 00000000..0c4f8f3d --- /dev/null +++ b/hub/server/domains/identity/run-provider-revision-transaction/index.ts @@ -0,0 +1,40 @@ +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 { classifyPrismaTransactionError } from "../../../utils/classify-prisma-transaction-error/index.ts" +import { IdentityTransactionConflictError } from "../errors.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( + (transaction) => + operation({ + createAuthenticationProvider: (input) => createAuthenticationProvider(transaction, input), + createAuthenticationProviderRevision: (input) => + createAuthenticationProviderRevision(transaction, input), + createSecurityAudit: (input) => createSecurityAudit(transaction, input), + findAuthenticationProvider: (key) => findProvider(transaction, key), + selectAuthenticationProviderRevision: (input) => + selectAuthenticationProviderRevision(transaction, input), + }), + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ) + .catch((error: unknown) => { + if (classifyPrismaTransactionError(error) === "retryable") { + 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..7617606a --- /dev/null +++ b/hub/server/domains/identity/run-provider-revision-transaction/types.ts @@ -0,0 +1,47 @@ +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" + +export interface SelectedProviderRevisionRecord { + id: string + providerId: string + revision: number + configurationDigest: string +} + +export interface AuthenticationProviderRecord { + id: string + key: string + selectedRevision: SelectedProviderRevisionRecord | null +} + +export type FindAuthenticationProviderForTransaction = ( + database: FindAuthenticationProviderDatabase, + key: string, +) => Promise + +export interface ProviderRevisionRepositories { + createAuthenticationProvider(input: CreateAuthenticationProviderInput): Promise + createAuthenticationProviderRevision( + input: CreateAuthenticationProviderRevisionInput, + ): Promise + createSecurityAudit(input: CreateSecurityAuditEvent): Promise + findAuthenticationProvider(key: string): Promise + selectAuthenticationProviderRevision( + input: SelectAuthenticationProviderRevisionInput, + ): Promise +} + +export type ProviderRevisionTransaction = ( + operation: (repositories: ProviderRevisionRepositories) => Promise, +) => Promise + +export interface ProviderRevisionDatabaseClient { + $transaction( + operation: (transaction: Prisma.TransactionClient) => Promise, + options: { isolationLevel: Prisma.TransactionIsolationLevel }, + ): 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 new file mode 100644 index 00000000..e04581b8 --- /dev/null +++ b/hub/server/domains/identity/run-with-bounded-retries/index.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest" + +import { IdentityTransactionConflictError } from "../errors" +import { runWithBoundedRetries } from "." + +describe("runWithBoundedRetries()", () => { + 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 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]) + }) + + it("stops at the fixed bound without consumer configuration", async () => { + vi.useFakeTimers() + const failure = new IdentityTransactionConflictError() + const operation = vi.fn(() => Promise.reject(failure)) + + 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("fails non-retryable errors once", async () => { + const failure = new Error("non-retryable") + const operation = vi.fn(() => Promise.reject(failure)) + + await expect( + runWithBoundedRetries( + operation, + (error) => error instanceof IdentityTransactionConflictError, + ), + ).rejects.toBe(failure) + expect(operation).toHaveBeenCalledOnce() + }) +}) 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..7902725d --- /dev/null +++ b/hub/server/domains/identity/run-with-bounded-retries/index.ts @@ -0,0 +1,22 @@ +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, + shouldRetry: (error: unknown) => boolean, +): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + return await operation(attempt) + } catch (error) { + 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/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..241fe06f --- /dev/null +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.db.test.ts @@ -0,0 +1,450 @@ +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 { createSecurityAudit } from "../../../repositories/security-audits/create-security-audit" +import { + IdentityTransactionConflictError, + ProviderConfigurationValidationError, + ProviderRevisionConflictError, + 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, + 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 (operation) => + runProviderRevisionTransaction(prisma, operation, async (database, key) => { + const provider = await findAuthenticationProvider(database, key) + if (arrivals < 2) { + arrivals += 1 + if (arrivals === 2) release?.() + await overlap + } + return provider + }) +} + +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("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" + + 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("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 + async function runTransaction( + operation: (repositories: ProviderRevisionRepositories) => Promise, + ) { + transactionCalls += 1 + return base.runTransaction(operation) + } + async function writeAudit(input: Parameters[1]) { + order.push("audit") + return base.writeAudit(input) + } + + await expect( + validateAndPersistProviderRevision(input(1), { + ...base, + runTransaction, + writeAudit, + }), + ).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) + 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("audits rejected configuration but rejects caller-supplied digest before validation", 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( + 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(1) + }) + + 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) + 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 () => { + 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("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() + 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..e75355cc --- /dev/null +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/index.ts @@ -0,0 +1,168 @@ +import { createHash } from "node:crypto" +import { z } from "zod" + +import { parseProviderKey } from "../../../utils/parse-provider-key/index.ts" +import { + IdentityTransactionConflictError, + ProviderConfigurationValidationError, + 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.unknown(), + 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 providerKey = parseProviderKey(parsed.providerKey) + 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, + 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(canonicalPublicConfiguration) + .digest("hex") + + try { + return await runWithBoundedRetries( + () => + dependencies.runTransaction(async (repositories) => { + const current = await getSelectedProviderRevision(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: 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, + revision: parsed.revision, + }) + + return { status: "selected", providerId, revisionId, revision: parsed.revision } + }), + (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, + revision: parsed.revision, + reason, + } + : { + id: dependencies.createId(), + type: "provider_revision_persist_failed", + 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..4704fa1a --- /dev/null +++ b/hub/server/domains/identity/validate-and-persist-provider-revision/types.ts @@ -0,0 +1,23 @@ +import type { CreateSecurityAuditEvent } from "../../../repositories/security-audits/create-security-audit/types.ts" +import type { ProviderRevisionTransaction } from "../run-provider-revision-transaction/types.ts" + +export interface ValidatedProviderConfiguration { + canonicalPublicConfiguration: string +} + +export interface ValidateAndPersistProviderRevisionDependencies { + createId(): string + 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/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..c6296250 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-authentication-provider-revision/types.ts @@ -0,0 +1,13 @@ +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 +} 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-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.test.ts b/hub/server/repositories/security-audits/create-security-audit/index.test.ts new file mode 100644 index 00000000..07d74ba4 --- /dev/null +++ b/hub/server/repositories/security-audits/create-security-audit/index.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from "vitest" + +import type { Prisma } from "../../../generated/prisma/client" +import { createSecurityAudit } from "." + +describe("createSecurityAudit()", () => { + 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" + + 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_validation_failed", + providerKey: "workforce", + revision: 1, + }) + 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(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", + }, + }), + 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", + 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("rejects unknown fields and caller-supplied context before writing", () => { + const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise>() + 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" } } + + expect(() => createSecurityAudit({ securityAudit: { create } }, withContext)).toThrow() + 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/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..e402be7c --- /dev/null +++ b/hub/server/repositories/security-audits/create-security-audit/index.ts @@ -0,0 +1,13 @@ +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" + +export function createSecurityAudit( + database: CreateSecurityAuditDatabase, + event: CreateSecurityAuditEvent, +): Promise +export function createSecurityAudit(database: CreateSecurityAuditDatabase, input: unknown) { + return database.securityAudit.create({ + data: getSecurityAuditData(parseSecurityAuditEvent(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..ed56b7b2 --- /dev/null +++ b/hub/server/repositories/security-audits/create-security-audit/types.ts @@ -0,0 +1,60 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export interface CreateSecurityAuditDatabase { + securityAudit: { + create(input: { data: Prisma.SecurityAuditCreateInput }): Promise + } +} + +interface SecurityAuditEvent { + id: string +} + +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 ProviderRevisionValidationFailedAudit extends SecurityAuditEvent { + type: "provider_revision_validation_failed" + 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 + | ProviderRevisionValidationFailedAudit + | ProviderRevisionSelectedAudit + | ProviderRevisionDeniedAudit + | ProviderRevisionPersistFailedAudit 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/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/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/normalize-bounded-json/index.test.ts b/hub/server/utils/normalize-bounded-json/index.test.ts new file mode 100644 index 00000000..bf176bae --- /dev/null +++ b/hub/server/utils/normalize-bounded-json/index.test.ts @@ -0,0 +1,135 @@ +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") + }) + + 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) + }) +}) diff --git a/hub/server/utils/normalize-bounded-json/index.ts b/hub/server/utils/normalize-bounded-json/index.ts new file mode 100644 index 00000000..056cf7aa --- /dev/null +++ b/hub/server/utils/normalize-bounded-json/index.ts @@ -0,0 +1,123 @@ +import type { + NormalizedJsonObject, + NormalizedJsonValue, + NormalizeBoundedJsonLimits, +} 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 normalizeBoundedJson( + value: unknown, + limits: NormalizeBoundedJsonLimits, +): 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-bounded-json/types.ts b/hub/server/utils/normalize-bounded-json/types.ts new file mode 100644 index 00000000..e571682c --- /dev/null +++ b/hub/server/utils/normalize-bounded-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 NormalizeBoundedJsonLimits { + maxArrayItems: number + maxBytes: number + maxDepth: number + maxEntries: number + maxKeyLength: number + maxObjectFields: number + maxStringLength: number +} 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/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..17631e37 --- /dev/null +++ b/hub/test/database/identity-foundation/index.db.test.ts @@ -0,0 +1,380 @@ +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" + +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>` + 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" }, + ]) + 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 () => { + 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, { + id: identityId, + userId: firstUserId, + provider: "workforce", + 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({ + 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 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("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 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 audit history immutable", async () => { + const { providerId, revisionId } = await createProviderAggregate("workforce") + const auditId = randomUUID() + 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: { configurationDigest: "b".repeat(64) }, + }), + ).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("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: unselectedSecondRevisionId, + providerId: second.providerId, + revision: 2, + configurationDigest: "c".repeat(64), + }, + }) + + 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), + }, + }), + ).rejects.toMatchObject({ code: "P2003" }) + await expect( + prisma.authenticationProvider.update({ + 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 () => { + 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) + }) + + 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, + }) +}