From 940d1a1feeb5b6520f9e37437cbcb9b05f898c2a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:25:42 +0000 Subject: [PATCH 1/5] feat(hub): persist identity records and credentials Add encrypted database and read-only environment credential providers for one GitHub installation, transactional identity repositories, and their storage contracts. Co-authored-by: Ben Thomas Co-authored-by: Nishiki Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 13 ++ hub/docs/README.md | 1 + hub/docs/architecture.md | 2 +- hub/docs/backend-architecture.md | 8 + hub/docs/coding-conventions.md | 6 +- hub/docs/database.md | 2 +- hub/docs/identity.md | 24 +++ hub/docs/testing-and-operations.md | 4 +- hub/package.json | 16 +- hub/schema/identity-state.prisma | 14 ++ .../20261026195151_identity/migration.sql | 89 ++++++++ hub/schema/migrations/migration_lock.toml | 3 + hub/schema/oauth-attempt.prisma | 21 ++ hub/schema/security-audit.prisma | 16 ++ hub/schema/session.prisma | 21 ++ hub/schema/user.prisma | 22 ++ .../create-credential-provider/index.test.ts | 93 +++++++++ .../create-credential-provider/index.ts | 34 ++++ .../create-credential-provider/types.ts | 8 + .../domains/identity/create-records/index.ts | 44 ++++ .../identity/create-store/index.db.test.ts | 192 ++++++++++++++++++ .../domains/identity/create-store/index.ts | 12 ++ .../domains/identity/create-store/types.ts | 31 +++ .../domains/identity/with-store-lock/index.ts | 12 ++ .../decrypt-github-credentials/index.ts | 22 ++ .../encrypt-github-credentials/constants.ts | 1 + .../encrypt-github-credentials/index.ts | 12 ++ .../integrations/github/github-error/index.ts | 10 + .../integrations/github/github-error/types.ts | 1 + .../validate-github-credentials/constants.ts | 16 ++ .../validate-github-credentials/index.ts | 30 +++ .../validate-github-credentials/types.ts | 7 + hub/server/integrations/prisma/index.ts | 2 +- .../advance-generation/index.ts | 11 + .../repositories/identity-state/constants.ts | 2 + .../identity-state/get-state/index.ts | 7 + .../identity-state/save-credentials/index.ts | 13 ++ .../repositories/identity-state/types.ts | 4 + .../with-identity-lock/index.ts | 18 ++ .../oauth-attempts/consume-attempt/index.ts | 20 ++ .../oauth-attempts/create-attempt/index.ts | 11 + .../repositories/oauth-attempts/types.ts | 11 + .../repositories/security-audits/constants.ts | 2 + .../security-audits/get-audits/index.ts | 11 + .../security-audits/save-audit/index.ts | 11 + .../repositories/security-audits/types.ts | 5 + .../sessions/create-session/index.ts | 7 + .../sessions/delete-session/index.ts | 6 + .../sessions/delete-sessions/index.ts | 5 + .../sessions/get-session-by-id/index.ts | 5 + .../sessions/get-session/index.ts | 5 + .../sessions/invalidate-freshness/index.ts | 5 + hub/server/repositories/sessions/types.ts | 8 + .../sessions/verify-session/index.ts | 13 ++ .../users/enroll-administrator/index.ts | 5 + .../users/get-github-user/index.ts | 7 + .../repositories/users/get-user/index.ts | 5 + .../users/save-github-user/index.ts | 11 + hub/server/repositories/users/types.ts | 11 + hub/tsconfig.json | 1 + hub/tsconfig.server.json | 2 +- 61 files changed, 1004 insertions(+), 7 deletions(-) create mode 100644 hub/docs/identity.md create mode 100644 hub/schema/identity-state.prisma create mode 100644 hub/schema/migrations/20261026195151_identity/migration.sql create mode 100644 hub/schema/migrations/migration_lock.toml create mode 100644 hub/schema/oauth-attempt.prisma create mode 100644 hub/schema/security-audit.prisma create mode 100644 hub/schema/session.prisma create mode 100644 hub/schema/user.prisma create mode 100644 hub/server/domains/identity/create-credential-provider/index.test.ts create mode 100644 hub/server/domains/identity/create-credential-provider/index.ts create mode 100644 hub/server/domains/identity/create-credential-provider/types.ts create mode 100644 hub/server/domains/identity/create-records/index.ts create mode 100644 hub/server/domains/identity/create-store/index.db.test.ts create mode 100644 hub/server/domains/identity/create-store/index.ts create mode 100644 hub/server/domains/identity/create-store/types.ts create mode 100644 hub/server/domains/identity/with-store-lock/index.ts create mode 100644 hub/server/integrations/github/decrypt-github-credentials/index.ts create mode 100644 hub/server/integrations/github/encrypt-github-credentials/constants.ts create mode 100644 hub/server/integrations/github/encrypt-github-credentials/index.ts create mode 100644 hub/server/integrations/github/github-error/index.ts create mode 100644 hub/server/integrations/github/github-error/types.ts create mode 100644 hub/server/integrations/github/validate-github-credentials/constants.ts create mode 100644 hub/server/integrations/github/validate-github-credentials/index.ts create mode 100644 hub/server/integrations/github/validate-github-credentials/types.ts create mode 100644 hub/server/repositories/identity-state/advance-generation/index.ts create mode 100644 hub/server/repositories/identity-state/constants.ts create mode 100644 hub/server/repositories/identity-state/get-state/index.ts create mode 100644 hub/server/repositories/identity-state/save-credentials/index.ts create mode 100644 hub/server/repositories/identity-state/types.ts create mode 100644 hub/server/repositories/identity-state/with-identity-lock/index.ts create mode 100644 hub/server/repositories/oauth-attempts/consume-attempt/index.ts create mode 100644 hub/server/repositories/oauth-attempts/create-attempt/index.ts create mode 100644 hub/server/repositories/oauth-attempts/types.ts create mode 100644 hub/server/repositories/security-audits/constants.ts create mode 100644 hub/server/repositories/security-audits/get-audits/index.ts create mode 100644 hub/server/repositories/security-audits/save-audit/index.ts create mode 100644 hub/server/repositories/security-audits/types.ts create mode 100644 hub/server/repositories/sessions/create-session/index.ts create mode 100644 hub/server/repositories/sessions/delete-session/index.ts create mode 100644 hub/server/repositories/sessions/delete-sessions/index.ts create mode 100644 hub/server/repositories/sessions/get-session-by-id/index.ts create mode 100644 hub/server/repositories/sessions/get-session/index.ts create mode 100644 hub/server/repositories/sessions/invalidate-freshness/index.ts create mode 100644 hub/server/repositories/sessions/types.ts create mode 100644 hub/server/repositories/sessions/verify-session/index.ts create mode 100644 hub/server/repositories/users/enroll-administrator/index.ts create mode 100644 hub/server/repositories/users/get-github-user/index.ts create mode 100644 hub/server/repositories/users/get-user/index.ts create mode 100644 hub/server/repositories/users/save-github-user/index.ts create mode 100644 hub/server/repositories/users/types.ts diff --git a/hub/.env.example b/hub/.env.example index ed2821cd..3dec67b0 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -13,3 +13,16 @@ DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica # Only database-enabled tests require this URL and CREATEDB permission. TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres + +# Select the credential provider. See docs/identity.md. +HUB_CREDENTIAL_PROVIDER=database + +# Generate separate values outside the database. See docs/identity.md. +# HUB_CREDENTIAL_KEY= + +# The environment provider is read-only and requires every GitHub setting below. +# GITHUB_APP_ID= +# GITHUB_INSTALLATION_ID= +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= +# GITHUB_PRIVATE_KEY= diff --git a/hub/docs/README.md b/hub/docs/README.md index 5d471ed3..66176753 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -9,6 +9,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Database](database.md): Prisma schema conventions, migrations, and the shared client. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Background jobs](background-jobs.md): PostgreSQL queues, workers, schedules, setup, and test isolation. +- [Identity](identity.md): credential storage and providers. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. - [Prototype canvas](prototype-canvas.md): the separately retained extension. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index bf72e320..5363239e 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. Identity models store credentials, users, sessions, sign-in attempts, and security records. [Credential providers](identity.md) use encrypted database values or read-only environment settings. Sign-in routes, 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 diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 60976740..1be8b7c7 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -37,6 +37,14 @@ Export route handlers by HTTP method, such as `get()`, and alias them to descrip Add directories when their functions are needed. Do not add empty implementations just to fill out the structure. +Keep function implementations flat. A composition factory may bind imported functions to dependencies, but must not contain their implementations. Put schemas and constants in a colocated `constants.ts`, and types in `types.ts`. + +Keep domain contracts with their domains, GitHub types and errors with that integration, and database inputs with their repositories. Keep only general helpers in utilities. + ## 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). + +Compose repository functions in domains. The identity store binds standalone queries to a database client or transaction there; a repository must not import its neighboring queries to build that store. + +`staticSettingsRecordId` identifies Hub's single settings row, not a person or GitHub installation. Its fixed UUID lets every process lock the same row. Keep it with the identity-state repository. diff --git a/hub/docs/coding-conventions.md b/hub/docs/coding-conventions.md index 31360d5d..cd990769 100644 --- a/hub/docs/coding-conventions.md +++ b/hub/docs/coding-conventions.md @@ -9,8 +9,12 @@ Follow the [database conventions](database.md#schema-conventions) for Prisma mod - Use TypeScript for new application code and tests. - Put each standalone function or component in a kebab-case directory with `index.ts` or `index.tsx`, colocated tests, types, and styles. - Export at most one function per file and import exact directories rather than sibling-export barrels. +- Keep helpers in their own function directories. Put schemas and constants in `constants.ts` and types in `types.ts`. - Group package, alias, and relative imports separately. -- Omit `/index.ts` and `/index.tsx` from imports in frontend code and Vitest tests. Keep explicit `.ts` file paths in server runtime and Node-loaded tooling, including Storybook configuration. Native Node does not resolve directory imports; the server compiler rewrites its paths for production. +- Use `@/` for browser code and `#server/` for API code. Import function directories without `/index.ts`, such as `#server/utils/digest-secret`. Import colocated constants and types from their named files. +- [Package imports](../package.json) resolve `#server/` to source with Node's `development` condition and to compiled JavaScript otherwise. `npm run dev:api` selects that condition; `npm start` uses compiled files. TypeScript and Vitest resolve source through the same mappings. Native Node package aliases must start with `#`; `@/` remains browser-only. +- Keep explicit `.ts` paths for relative imports in native Node modules and tooling, including Storybook configuration. The server compiler rewrites relative extensions for the build. +- Prefer named alias imports such as `#server/integrations/github/identify-user/constants` over relative paths when a mapping exists. Vite resolves extensionless relative paths, but native Node does not. TypeScript `paths` alone cannot make `@/server/...` work in Node or rewrite it in emitted JavaScript. - Keep HTTP parsing and responses outside domains. Validate input and return explicit errors when introducing real API behavior. ## Frontend layers diff --git a/hub/docs/database.md b/hub/docs/database.md index 1663e7f1..3f176ea1 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. Product models and migrations have not been added. +Hub uses PostgreSQL and Prisma. The initial migration stores the [identity boundary](identity.md); product models have not been added. [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. diff --git a/hub/docs/identity.md b/hub/docs/identity.md new file mode 100644 index 00000000..9e0d375f --- /dev/null +++ b/hub/docs/identity.md @@ -0,0 +1,24 @@ +# Hub identity + +Hub stores GitHub credentials, users, sessions, one-use sign-in attempts, and security records in PostgreSQL. Credential providers use one GitHub App installation. + +## Credential providers + +Set `HUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): + +| Provider | Credentials | +| --- | --- | +| `database` (default) | Generate `HUB_CREDENTIAL_KEY` with `openssl rand -hex 32`. The provider encrypts credentials before storage and checks them when reading. | +| `environment` | Set every `GITHUB_*` value, including `GITHUB_INSTALLATION_ID`. This provider is read-only and ignores database credentials. | + +Both providers require an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. + +Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors; Hub never falls back to another provider. Never commit credentials. + +## Saved records + +Apply the committed migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. + +Domain functions bind repository queries to a client or transaction. A fixed settings row lets overlapping changes lock the same record. Credential updates, session changes, and their audit records can commit together or roll back together. + +User records use permanent GitHub IDs, not names or emails. Repositories consume each sign-in attempt once. Audit reads return the latest 100 records within 90 days; later writes remove older records. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 6d23554f..1ce32174 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -34,6 +34,8 @@ Arrange each case's inputs and records inside that case. Act through its public Tests must release resources they create, such as servers, files, sockets, and event listeners. Use `try/finally` or register disposal with Vitest's `onTestFinished` when creating the resource. Clearing the module cache only affects subsequent imports. Reset any deliberately shared fixture state before each case. +Keep shared server test helpers under the owning function's `test/` directory. TypeScript checks them, but the API build excludes them. + ## Parallel database tests Each test run uses separate databases so parallel tests cannot change another worker's records or clear the development database. Vitest runs files in parallel in [forked worker processes](https://vitest.dev/config/pool). Cases within a worker run sequentially, including cases marked concurrent, so database resets cannot erase another case's records. Use `--maxWorkers` to set file-level parallelism. @@ -51,7 +53,7 @@ Use `.test.ts` or `.test.tsx` for ordinary cases. Use `.db.test.ts` or `.db.test Start the PostgreSQL service as described in [Development](development.md) and set `TEST_DATABASE_URL` using `.env.example`. The devcontainer supplies this variable. Use a local database user with permission to create and drop databases, never a production connection. -The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. The job integration cases exercise real PostgreSQL queues, schedules, and database resets. +The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise persistence, one-use attempts, transaction rollback, and concurrent revocation locks. Job integration cases exercise real PostgreSQL queues, schedules, and database resets. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. diff --git a/hub/package.json b/hub/package.json index 259f8e00..19a98b76 100644 --- a/hub/package.json +++ b/hub/package.json @@ -3,6 +3,20 @@ "version": "0.1.0", "private": true, "type": "module", + "imports": { + "#server/*/constants": { + "development": "./server/*/constants.ts", + "default": "./dist/server/*/constants.js" + }, + "#server/*/types": { + "development": "./server/*/types.ts", + "default": "./dist/server/*/types.js" + }, + "#server/*": { + "development": "./server/*/index.ts", + "default": "./dist/server/*/index.js" + } + }, "engines": { "node": "^24.15.0 || ^26.0.0" }, @@ -30,7 +44,7 @@ "build": "npm run typecheck && vite build && tsc --project tsconfig.server.json", "dev": "npm run dev:client", "dev:client": "vite --host 127.0.0.1", - "dev:api": "node --watch server/index.ts", + "dev:api": "node --conditions=development --watch server/index.ts", "start": "node dist/server/index.js", "preview": "vite preview --host 127.0.0.1", "extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.mjs", diff --git a/hub/schema/identity-state.prisma b/hub/schema/identity-state.prisma new file mode 100644 index 00000000..77757ab5 --- /dev/null +++ b/hub/schema/identity-state.prisma @@ -0,0 +1,14 @@ +model IdentityState { + // ID columns + id String @id @db.Uuid + + // Attribute columns + generation Int @default(0) + encryptedCredentials String? @map("encrypted_credentials") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("identity_states") +} diff --git a/hub/schema/migrations/20261026195151_identity/migration.sql b/hub/schema/migrations/20261026195151_identity/migration.sql new file mode 100644 index 00000000..ec013404 --- /dev/null +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -0,0 +1,89 @@ +-- CreateTable +CREATE TABLE "identity_states" ( + "id" UUID NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "encrypted_credentials" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "identity_states_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "oauth_attempts" ( + "id" UUID NOT NULL, + "session_id" UUID, + "state_digest" TEXT NOT NULL, + "browser_digest" TEXT NOT NULL, + "verifier" TEXT NOT NULL, + "generation" INTEGER NOT NULL, + "credential_revision" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "oauth_attempts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "security_audits" ( + "id" UUID NOT NULL, + "actor_id" UUID, + "session_id" UUID, + "event" TEXT 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") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "token_digest" TEXT NOT NULL, + "signed_in_at" TIMESTAMPTZ(3) NOT NULL, + "fresh_authorized_at" TIMESTAMPTZ(3), + "credential_revision" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "provider" TEXT NOT NULL DEFAULT 'github', + "provider_id" TEXT NOT NULL, + "login" TEXT NOT NULL, + "name" TEXT, + "avatar_url" TEXT, + "administrator" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_attempts_state_digest_key" ON "oauth_attempts"("state_digest"); + +-- CreateIndex +CREATE INDEX "oauth_attempts_expires_at_idx" ON "oauth_attempts"("expires_at"); + +-- CreateIndex +CREATE INDEX "security_audits_created_at_idx" ON "security_audits"("created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_digest_key" ON "sessions"("token_digest"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_provider_provider_id_key" ON "users"("provider", "provider_id"); + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/schema/migrations/migration_lock.toml b/hub/schema/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/hub/schema/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/hub/schema/oauth-attempt.prisma b/hub/schema/oauth-attempt.prisma new file mode 100644 index 00000000..6495ce32 --- /dev/null +++ b/hub/schema/oauth-attempt.prisma @@ -0,0 +1,21 @@ +model OAuthAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + sessionId String? @map("session_id") @db.Uuid + + // Attribute columns + stateDigest String @unique @map("state_digest") + browserDigest String @map("browser_digest") + verifier String + generation Int + credentialRevision String @map("credential_revision") + purpose String + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([expiresAt]) + @@map("oauth_attempts") +} diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma new file mode 100644 index 00000000..1496c4bf --- /dev/null +++ b/hub/schema/security-audit.prisma @@ -0,0 +1,16 @@ +model SecurityAudit { + // ID columns + id String @id @default(uuid()) @db.Uuid + actorId String? @map("actor_id") @db.Uuid + sessionId String? @map("session_id") @db.Uuid + + // Attribute columns + event String + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([createdAt]) + @@map("security_audits") +} diff --git a/hub/schema/session.prisma b/hub/schema/session.prisma new file mode 100644 index 00000000..60738974 --- /dev/null +++ b/hub/schema/session.prisma @@ -0,0 +1,21 @@ +model Session { + // 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: Cascade) + + // Attribute columns + tokenDigest String @unique @map("token_digest") + signedInAt DateTime @map("signed_in_at") @db.Timestamptz(3) + freshAuthorizedAt DateTime? @map("fresh_authorized_at") @db.Timestamptz(3) + credentialRevision String @map("credential_revision") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([userId]) + @@map("sessions") +} diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma new file mode 100644 index 00000000..39a567d5 --- /dev/null +++ b/hub/schema/user.prisma @@ -0,0 +1,22 @@ +model User { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + sessions Session[] + + // Attribute columns + provider String @default("github") + providerId String @map("provider_id") + login String + name String? + avatarUrl String? @map("avatar_url") + administrator Boolean @default(false) + + // 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, providerId]) + @@map("users") +} diff --git a/hub/server/domains/identity/create-credential-provider/index.test.ts b/hub/server/domains/identity/create-credential-provider/index.test.ts new file mode 100644 index 00000000..05f34d2e --- /dev/null +++ b/hub/server/domains/identity/create-credential-provider/index.test.ts @@ -0,0 +1,93 @@ +import { generateKeyPairSync } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { createCredentialProvider } from "#server/domains/identity/create-credential-provider" + +function credentials() { + return { + appId: "12345", + installationId: "7", + clientId: "test-client", + clientSecret: "synthetic-client-secret", + privateKey: generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey, + } +} + +describe("createCredentialProvider()", () => { + it("starts unconfigured and encrypts database credentials with fresh authenticated ciphertext", () => { + const provider = createCredentialProvider({ HUB_CREDENTIAL_KEY: "ab".repeat(32) }) + const input = credentials() + + expect(provider.name).toBe("database") + expect(provider.read({ generation: 0, encryptedCredentials: null })).toBeNull() + const encrypted = provider.encrypt!(input) + expect(provider.encrypt!(input)).not.toBe(encrypted) + expect(provider.read({ generation: 0, encryptedCredentials: encrypted })).toEqual(input) + expect(encrypted).not.toContain(input.clientSecret) + expect(Buffer.from(encrypted, "base64").toString()).not.toContain(input.privateKey) + + const tampered = Buffer.from(encrypted, "base64") + tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 1 + expect(() => + provider.read({ generation: 0, encryptedCredentials: tampered.toString("base64") }), + ).toThrow() + const otherKey = createCredentialProvider({ HUB_CREDENTIAL_KEY: "cd".repeat(32) }) + expect(() => otherKey.read({ generation: 0, encryptedCredentials: encrypted })).toThrow() + }) + + it("requires a valid encryption key outside the database", () => { + for (const key of [undefined, "ab".repeat(31), "zz".repeat(32)]) { + expect(() => createCredentialProvider({ HUB_CREDENTIAL_KEY: key })).toThrow() + } + }) + + it("uses complete environment credentials read-only without consulting database ciphertext", () => { + const input = credentials() + const provider = createCredentialProvider({ + HUB_CREDENTIAL_PROVIDER: "environment", + GITHUB_APP_ID: input.appId, + GITHUB_INSTALLATION_ID: input.installationId, + GITHUB_CLIENT_ID: input.clientId, + GITHUB_CLIENT_SECRET: input.clientSecret, + GITHUB_PRIVATE_KEY: input.privateKey, + }) + + expect(provider.name).toBe("environment") + expect(provider.encrypt).toBeUndefined() + expect( + provider.read({ generation: 9, encryptedCredentials: "not-database-credentials" }), + ).toEqual(input) + }) + + it("rejects incomplete environment configuration and unknown providers instead of falling back", () => { + const input = credentials() + const environment = { + HUB_CREDENTIAL_PROVIDER: "environment", + HUB_CREDENTIAL_KEY: "ab".repeat(32), + GITHUB_APP_ID: input.appId, + GITHUB_INSTALLATION_ID: input.installationId, + GITHUB_CLIENT_ID: input.clientId, + GITHUB_CLIENT_SECRET: input.clientSecret, + GITHUB_PRIVATE_KEY: input.privateKey, + } + for (const field of [ + "GITHUB_APP_ID", + "GITHUB_INSTALLATION_ID", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GITHUB_PRIVATE_KEY", + ] as const) { + expect(() => createCredentialProvider({ ...environment, [field]: undefined })).toThrow() + } + expect(() => + createCredentialProvider({ ...environment, GITHUB_PRIVATE_KEY: "invalid" }), + ).toThrow() + expect(() => + createCredentialProvider({ ...environment, HUB_CREDENTIAL_PROVIDER: "other" }), + ).toThrow() + }) +}) diff --git a/hub/server/domains/identity/create-credential-provider/index.ts b/hub/server/domains/identity/create-credential-provider/index.ts new file mode 100644 index 00000000..3b6f661e --- /dev/null +++ b/hub/server/domains/identity/create-credential-provider/index.ts @@ -0,0 +1,34 @@ +import { decryptGitHubCredentials } from "#server/integrations/github/decrypt-github-credentials" +import { encryptGitHubCredentials } from "#server/integrations/github/encrypt-github-credentials" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { CredentialProvider } from "#server/domains/identity/create-credential-provider/types" + +export function createCredentialProvider(environment: NodeJS.ProcessEnv): CredentialProvider { + const selected = environment.HUB_CREDENTIAL_PROVIDER ?? "database" + if (selected === "environment") { + const credentials = validateGitHubCredentials({ + appId: environment.GITHUB_APP_ID, + installationId: environment.GITHUB_INSTALLATION_ID, + clientId: environment.GITHUB_CLIENT_ID, + clientSecret: environment.GITHUB_CLIENT_SECRET, + privateKey: environment.GITHUB_PRIVATE_KEY, + }) + return { name: "environment", read: () => credentials } + } + if (selected !== "database") { + throw new Error("HUB_CREDENTIAL_PROVIDER must be database or environment.") + } + + const encodedKey = environment.HUB_CREDENTIAL_KEY + if (!encodedKey || !/^[a-fA-F0-9]{64}$/.test(encodedKey)) { + throw new Error( + "Set HUB_CREDENTIAL_KEY to a 32-byte hexadecimal encryption key outside the database.", + ) + } + const key = Buffer.from(encodedKey, "hex") + return { + name: "database", + read: (state) => decryptGitHubCredentials(key, state.encryptedCredentials), + encrypt: encryptGitHubCredentials.bind(null, key), + } +} diff --git a/hub/server/domains/identity/create-credential-provider/types.ts b/hub/server/domains/identity/create-credential-provider/types.ts new file mode 100644 index 00000000..db294218 --- /dev/null +++ b/hub/server/domains/identity/create-credential-provider/types.ts @@ -0,0 +1,8 @@ +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import type { IdentityState } from "#server/repositories/identity-state/types" + +export interface CredentialProvider { + name: "database" | "environment" + read(state: IdentityState): GitHubCredentials | null + encrypt?: (credentials: GitHubCredentials) => string +} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts new file mode 100644 index 00000000..81e7074c --- /dev/null +++ b/hub/server/domains/identity/create-records/index.ts @@ -0,0 +1,44 @@ +import { advanceIdentityGeneration } from "#server/repositories/identity-state/advance-generation" +import { getIdentityState } from "#server/repositories/identity-state/get-state" +import { saveIdentityCredentials } from "#server/repositories/identity-state/save-credentials" +import { consumeOAuthAttempt } from "#server/repositories/oauth-attempts/consume-attempt" +import { createOAuthAttempt } from "#server/repositories/oauth-attempts/create-attempt" +import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { createSession } from "#server/repositories/sessions/create-session" +import { deleteSession } from "#server/repositories/sessions/delete-session" +import { deleteSessions } from "#server/repositories/sessions/delete-sessions" +import { getSession } from "#server/repositories/sessions/get-session" +import { getSessionById } from "#server/repositories/sessions/get-session-by-id" +import { invalidateFreshness } from "#server/repositories/sessions/invalidate-freshness" +import { verifySession } from "#server/repositories/sessions/verify-session" +import { enrollAdministrator } from "#server/repositories/users/enroll-administrator" +import { getGitHubUser } from "#server/repositories/users/get-github-user" +import { getUser } from "#server/repositories/users/get-user" +import { saveGitHubUser } from "#server/repositories/users/save-github-user" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function createIdentityRecords(database: Prisma.TransactionClient): IdentityRecords { + return { + state: getIdentityState.bind(null, database), + saveCredentials: saveIdentityCredentials.bind(null, database), + advanceGeneration: advanceIdentityGeneration.bind(null, database), + user: getUser.bind(null, database), + githubUser: getGitHubUser.bind(null, database), + saveUser: saveGitHubUser.bind(null, database), + enroll: enrollAdministrator.bind(null, database), + session: getSession.bind(null, database), + sessionById: getSessionById.bind(null, database), + createSession: createSession.bind(null, database), + verifySession: verifySession.bind(null, database), + invalidateFreshness: invalidateFreshness.bind(null, database), + deleteSession: deleteSession.bind(null, database), + deleteSessions: deleteSessions.bind(null, database), + createAttempt: createOAuthAttempt.bind(null, database), + consumeAttempt: consumeOAuthAttempt.bind(null, database), + audit: saveSecurityAudit.bind(null, database), + audits: getSecurityAudits.bind(null, database), + } +} diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts new file mode 100644 index 00000000..07b66171 --- /dev/null +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createIdentityStore } from "#server/domains/identity/create-store" + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { prisma, store: createIdentityStore(prisma) } +} + +describe("createIdentityStore()", () => { + it("rolls back credential, session, generation, and audit changes together", async () => { + const { store } = await database() + await store.lock(async (records) => { + const user = await records.saveUser({ + providerId: "10001", + login: "synthetic-user", + name: null, + avatarUrl: null, + }) + await records.saveCredentials("initial-encrypted-fixture") + await records.createSession({ + userId: user.id, + tokenDigest: "synthetic-token-digest", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + credentialRevision: "initial-revision", + }) + }) + + await expect( + store.lock(async (records) => { + await records.saveCredentials("replacement-encrypted-fixture") + await records.advanceGeneration() + await records.deleteSessions() + await records.audit({ event: "session.revoke_all", actorId: null, sessionId: null }) + throw new Error("Transaction failed") + }), + ).rejects.toThrow("Transaction failed") + + expect(await store.read.state()).toMatchObject({ + generation: 0, + encryptedCredentials: "initial-encrypted-fixture", + }) + expect(await store.read.session("synthetic-token-digest")).not.toBeNull() + expect(await store.read.audits()).toEqual([]) + }) + + it("serializes locks so an overlapping mutation reads the committed generation", async () => { + const { store } = await database() + await store.lock(() => Promise.resolve()) + let entered!: () => void + let release!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const first = store.lock(async (records) => { + await records.advanceGeneration() + entered() + await gate + }) + await started + const second = store.lock(async (records) => { + const state = await records.state() + await records.advanceGeneration() + return state.generation + }) + release() + + await first + expect(await second).toBe(1) + expect((await store.read.state()).generation).toBe(2) + }) + + it("upserts by immutable GitHub account rather than a reused login", async () => { + const { store } = await database() + const profile = { providerId: "10001", login: "synthetic-user", name: null, avatarUrl: null } + const original = await store.lock((records) => records.saveUser(profile)) + await store.lock((records) => records.enroll(original.id)) + const renamed = await store.lock((records) => + records.saveUser({ ...profile, login: "renamed-user" }), + ) + const reused = await store.lock((records) => + records.saveUser({ ...profile, providerId: "20002" }), + ) + + expect(renamed.id).toBe(original.id) + expect(renamed.administrator).toBe(true) + expect(reused.id).not.toBe(original.id) + expect(reused.administrator).toBe(false) + }) + + it("consumes an OAuth attempt once across concurrent transactions and clears pending attempts on a cutoff", async () => { + const { store } = await database() + const input = { + stateDigest: "synthetic-state-digest", + browserDigest: "synthetic-browser-digest", + verifier: "synthetic-verifier", + generation: 0, + credentialRevision: "synthetic-revision", + purpose: "sign-in" as const, + sessionId: null, + expiresAt: new Date(Date.now() + 15 * 60_000), + } + await store.lock((records) => records.createAttempt(input)) + const results = await Promise.all([ + store.lock((records) => records.consumeAttempt(input.stateDigest)), + store.lock((records) => records.consumeAttempt(input.stateDigest)), + ]) + expect(results.filter(Boolean)).toHaveLength(1) + expect(results.find(Boolean)).toMatchObject(input) + + await store.lock((records) => records.createAttempt({ ...input, stateDigest: "pending-state" })) + await store.lock((records) => records.advanceGeneration()) + expect(await store.lock((records) => records.consumeAttempt("pending-state"))).toBeNull() + }) + + it("invalidates fresh proof without deleting sessions and removes originating attempts on sign-out", async () => { + const { store } = await database() + const session = await store.lock(async (records) => { + const user = await records.saveUser({ + providerId: "10001", + login: "synthetic-user", + name: null, + avatarUrl: null, + }) + const session = await records.createSession({ + userId: user.id, + tokenDigest: "synthetic-token-digest", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + credentialRevision: "synthetic-revision", + }) + await records.createAttempt({ + stateDigest: "synthetic-state-digest", + browserDigest: "synthetic-browser-digest", + verifier: "synthetic-verifier", + generation: 0, + credentialRevision: "synthetic-revision", + purpose: "verify", + sessionId: session.id, + expiresAt: new Date(Date.now() + 15 * 60_000), + }) + return session + }) + + await store.lock((records) => records.invalidateFreshness()) + expect(await store.read.sessionById(session.id)).toMatchObject({ freshAuthorizedAt: null }) + await store.lock((records) => records.deleteSession(session.id)) + expect(await store.read.session("synthetic-token-digest")).toBeNull() + expect( + await store.lock((records) => records.consumeAttempt("synthetic-state-digest")), + ).toBeNull() + }) + + it("hides audits older than ninety days and prunes them on writes while retaining the cutoff", async () => { + const { prisma, store } = await database() + const now = Date.now() + vi.spyOn(Date, "now").mockReturnValue(now) + const cutoff = now - 90 * 24 * 60 * 60_000 + const old = await prisma.securityAudit.create({ + data: { + event: "identity.denied.request_failed", + createdAt: new Date(cutoff - 1), + }, + }) + const retained = await prisma.securityAudit.create({ + data: { + event: "session.sign_in", + createdAt: new Date(cutoff), + }, + }) + + expect((await store.read.audits()).map(({ id }) => id)).toEqual([retained.id]) + expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).not.toBeNull() + await store.lock((records) => + records.audit({ event: "credentials.setup", actorId: null, sessionId: null }), + ) + + expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).toBeNull() + expect(await prisma.securityAudit.findUnique({ where: { id: retained.id } })).not.toBeNull() + expect((await store.read.audits()).map(({ event }) => event)).toEqual([ + "credentials.setup", + "session.sign_in", + ]) + expect(await prisma.securityAudit.count()).toBe(2) + }) +}) diff --git a/hub/server/domains/identity/create-store/index.ts b/hub/server/domains/identity/create-store/index.ts new file mode 100644 index 00000000..73da355b --- /dev/null +++ b/hub/server/domains/identity/create-store/index.ts @@ -0,0 +1,12 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { withIdentityStoreLock } from "#server/domains/identity/with-store-lock" +import type { IdentityStore } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function createIdentityStore(client: PrismaClient): IdentityStore { + return { + read: createIdentityRecords(client), + lock: (operation) => withIdentityStoreLock(client, operation), + } +} diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts new file mode 100644 index 00000000..39d3a962 --- /dev/null +++ b/hub/server/domains/identity/create-store/types.ts @@ -0,0 +1,31 @@ +import type { IdentityState } from "#server/repositories/identity-state/types" +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" +import type { AuditInput } from "#server/repositories/security-audits/types" +import type { HubSession } from "#server/repositories/sessions/types" +import type { GitHubUserInput, HubUser } from "#server/repositories/users/types" + +export interface IdentityRecords { + state(): Promise + saveCredentials(encryptedCredentials: string): Promise + advanceGeneration(): Promise + user(id: string): Promise + githubUser(providerId: string): Promise + saveUser(profile: GitHubUserInput): Promise + enroll(userId: string): Promise + session(tokenDigest: string): Promise<(HubSession & { user: HubUser }) | null> + sessionById(id: string): Promise<(HubSession & { user: HubUser }) | null> + createSession(input: Omit): Promise + verifySession(id: string, at: Date, credentialRevision: string): Promise + invalidateFreshness(): Promise + deleteSession(id: string): Promise + deleteSessions(): Promise + createAttempt(input: Omit): Promise + consumeAttempt(stateDigest: string): Promise + audit(input: AuditInput): Promise + audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> +} + +export interface IdentityStore { + read: IdentityRecords + lock(operation: (records: IdentityRecords) => Promise): Promise +} diff --git a/hub/server/domains/identity/with-store-lock/index.ts b/hub/server/domains/identity/with-store-lock/index.ts new file mode 100644 index 00000000..7613f7fa --- /dev/null +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -0,0 +1,12 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { withIdentityLock } from "#server/repositories/identity-state/with-identity-lock" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityStoreLock( + client: PrismaClient, + operation: (records: IdentityRecords) => Promise, +): Promise { + return withIdentityLock(client, (database) => operation(createIdentityRecords(database))) +} diff --git a/hub/server/integrations/github/decrypt-github-credentials/index.ts b/hub/server/integrations/github/decrypt-github-credentials/index.ts new file mode 100644 index 00000000..d25b1c18 --- /dev/null +++ b/hub/server/integrations/github/decrypt-github-credentials/index.ts @@ -0,0 +1,22 @@ +import { createDecipheriv } from "node:crypto" + +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function decryptGitHubCredentials( + key: Buffer, + encryptedCredentials: string | null, +): GitHubCredentials | null { + if (encryptedCredentials === null) return null + try { + const data = Buffer.from(encryptedCredentials, "base64") + const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12)) + decipher.setAAD(credentialContext) + decipher.setAuthTag(data.subarray(12, 28)) + const plaintext = Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]) + return validateGitHubCredentials(JSON.parse(plaintext.toString("utf8"))) + } catch { + throw new Error("Cannot decrypt GitHub App credentials. Restore the configured encryption key.") + } +} diff --git a/hub/server/integrations/github/encrypt-github-credentials/constants.ts b/hub/server/integrations/github/encrypt-github-credentials/constants.ts new file mode 100644 index 00000000..45c9388c --- /dev/null +++ b/hub/server/integrations/github/encrypt-github-credentials/constants.ts @@ -0,0 +1 @@ +export const credentialContext = Buffer.from("replica:github-app:v1") diff --git a/hub/server/integrations/github/encrypt-github-credentials/index.ts b/hub/server/integrations/github/encrypt-github-credentials/index.ts new file mode 100644 index 00000000..56876cdc --- /dev/null +++ b/hub/server/integrations/github/encrypt-github-credentials/index.ts @@ -0,0 +1,12 @@ +import { createCipheriv, randomBytes } from "node:crypto" + +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function encryptGitHubCredentials(key: Buffer, credentials: GitHubCredentials): string { + const nonce = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", key, nonce) + cipher.setAAD(credentialContext) + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(credentials)), cipher.final()]) + return Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString("base64") +} diff --git a/hub/server/integrations/github/github-error/index.ts b/hub/server/integrations/github/github-error/index.ts new file mode 100644 index 00000000..9be9cfe6 --- /dev/null +++ b/hub/server/integrations/github/github-error/index.ts @@ -0,0 +1,10 @@ +import type { GitHubErrorCode } from "#server/integrations/github/github-error/types" + +export class GitHubError extends Error { + code: GitHubErrorCode + + constructor(code: GitHubErrorCode, message: string) { + super(message) + this.code = code + } +} diff --git a/hub/server/integrations/github/github-error/types.ts b/hub/server/integrations/github/github-error/types.ts new file mode 100644 index 00000000..a5fdfb96 --- /dev/null +++ b/hub/server/integrations/github/github-error/types.ts @@ -0,0 +1 @@ +export type GitHubErrorCode = "invalid_credentials" diff --git a/hub/server/integrations/github/validate-github-credentials/constants.ts b/hub/server/integrations/github/validate-github-credentials/constants.ts new file mode 100644 index 00000000..3a497c7e --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/constants.ts @@ -0,0 +1,16 @@ +import { z } from "zod" + +const githubIdSchema = z + .string() + .regex(/^[1-9]\d*$/) + .max(30) + +export const credentialsSchema = z + .object({ + appId: githubIdSchema, + installationId: githubIdSchema, + clientId: z.string().min(1).max(200), + clientSecret: z.string().min(1).max(1000), + privateKey: z.string().min(1).max(16384), + }) + .strict() diff --git a/hub/server/integrations/github/validate-github-credentials/index.ts b/hub/server/integrations/github/validate-github-credentials/index.ts new file mode 100644 index 00000000..744553d5 --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/index.ts @@ -0,0 +1,30 @@ +import { createPrivateKey } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { GitHubError } from "#server/integrations/github/github-error" + +import { credentialsSchema } from "#server/integrations/github/validate-github-credentials/constants" + +export function validateGitHubCredentials(input: unknown): GitHubCredentials { + const parsed = credentialsSchema.safeParse(input) + if (!parsed.success) { + throw new GitHubError( + "invalid_credentials", + "Supply the GitHub App ID, installation ID, client ID, client secret, and RSA private key.", + ) + } + + try { + const key = createPrivateKey(parsed.data.privateKey) + if (key.asymmetricKeyType !== "rsa" || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) { + throw new Error("Invalid key") + } + } catch { + throw new GitHubError( + "invalid_credentials", + "The GitHub App private key must be an RSA key of at least 2048 bits.", + ) + } + + return parsed.data +} diff --git a/hub/server/integrations/github/validate-github-credentials/types.ts b/hub/server/integrations/github/validate-github-credentials/types.ts new file mode 100644 index 00000000..d45f9941 --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/types.ts @@ -0,0 +1,7 @@ +export interface GitHubCredentials { + appId: string + installationId: string + clientId: string + clientSecret: string + privateKey: string +} diff --git a/hub/server/integrations/prisma/index.ts b/hub/server/integrations/prisma/index.ts index f9c0d9b0..78f43a6d 100644 --- a/hub/server/integrations/prisma/index.ts +++ b/hub/server/integrations/prisma/index.ts @@ -1,5 +1,5 @@ -import "dotenv/config" import { PrismaPg } from "@prisma/adapter-pg" +import "dotenv/config" import { PrismaClient } from "../../generated/prisma/client.ts" diff --git a/hub/server/repositories/identity-state/advance-generation/index.ts b/hub/server/repositories/identity-state/advance-generation/index.ts new file mode 100644 index 00000000..899e0c96 --- /dev/null +++ b/hub/server/repositories/identity-state/advance-generation/index.ts @@ -0,0 +1,11 @@ +import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function advanceIdentityGeneration(database: Prisma.TransactionClient) { + await database.identityState.update({ + where: { id: staticSettingsRecordId }, + data: { generation: { increment: 1 } }, + }) + await database.oAuthAttempt.deleteMany() +} diff --git a/hub/server/repositories/identity-state/constants.ts b/hub/server/repositories/identity-state/constants.ts new file mode 100644 index 00000000..19030771 --- /dev/null +++ b/hub/server/repositories/identity-state/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process must lock the same settings row before changing identity state. +export const staticSettingsRecordId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/identity-state/get-state/index.ts b/hub/server/repositories/identity-state/get-state/index.ts new file mode 100644 index 00000000..401138ae --- /dev/null +++ b/hub/server/repositories/identity-state/get-state/index.ts @@ -0,0 +1,7 @@ +import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getIdentityState(database: Prisma.TransactionClient) { + return database.identityState.findUniqueOrThrow({ where: { id: staticSettingsRecordId } }) +} diff --git a/hub/server/repositories/identity-state/save-credentials/index.ts b/hub/server/repositories/identity-state/save-credentials/index.ts new file mode 100644 index 00000000..264b0ebb --- /dev/null +++ b/hub/server/repositories/identity-state/save-credentials/index.ts @@ -0,0 +1,13 @@ +import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveIdentityCredentials( + database: Prisma.TransactionClient, + encryptedCredentials: string, +) { + await database.identityState.update({ + where: { id: staticSettingsRecordId }, + data: { encryptedCredentials }, + }) +} diff --git a/hub/server/repositories/identity-state/types.ts b/hub/server/repositories/identity-state/types.ts new file mode 100644 index 00000000..19042699 --- /dev/null +++ b/hub/server/repositories/identity-state/types.ts @@ -0,0 +1,4 @@ +export interface IdentityState { + generation: number + encryptedCredentials: string | null +} diff --git a/hub/server/repositories/identity-state/with-identity-lock/index.ts b/hub/server/repositories/identity-state/with-identity-lock/index.ts new file mode 100644 index 00000000..4f964f4b --- /dev/null +++ b/hub/server/repositories/identity-state/with-identity-lock/index.ts @@ -0,0 +1,18 @@ +import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityLock( + client: PrismaClient, + operation: (database: Prisma.TransactionClient) => Promise, +): Promise { + return client.$transaction(async (transaction) => { + await transaction.identityState.upsert({ + where: { id: staticSettingsRecordId }, + create: { id: staticSettingsRecordId }, + update: {}, + }) + await transaction.$queryRaw`SELECT id FROM identity_states WHERE id = ${staticSettingsRecordId}::uuid FOR UPDATE` + return operation(transaction) + }) +} diff --git a/hub/server/repositories/oauth-attempts/consume-attempt/index.ts b/hub/server/repositories/oauth-attempts/consume-attempt/index.ts new file mode 100644 index 00000000..fee04b3e --- /dev/null +++ b/hub/server/repositories/oauth-attempts/consume-attempt/index.ts @@ -0,0 +1,20 @@ +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function consumeOAuthAttempt( + database: Prisma.TransactionClient, + stateDigest: string, +): Promise { + const attempt = await database.oAuthAttempt.findUnique({ where: { stateDigest } }) + if (!attempt) return null + await database.oAuthAttempt.delete({ where: { id: attempt.id } }) + if ( + attempt.purpose !== "sign-in" && + attempt.purpose !== "verify" && + attempt.purpose !== "enroll" + ) { + throw new Error("Stored OAuth purpose is invalid.") + } + return { ...attempt, purpose: attempt.purpose } +} diff --git a/hub/server/repositories/oauth-attempts/create-attempt/index.ts b/hub/server/repositories/oauth-attempts/create-attempt/index.ts new file mode 100644 index 00000000..fad43ef1 --- /dev/null +++ b/hub/server/repositories/oauth-attempts/create-attempt/index.ts @@ -0,0 +1,11 @@ +import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createOAuthAttempt( + database: Prisma.TransactionClient, + data: Omit, +) { + await database.oAuthAttempt.deleteMany({ where: { expiresAt: { lte: new Date() } } }) + await database.oAuthAttempt.create({ data }) +} diff --git a/hub/server/repositories/oauth-attempts/types.ts b/hub/server/repositories/oauth-attempts/types.ts new file mode 100644 index 00000000..91d56160 --- /dev/null +++ b/hub/server/repositories/oauth-attempts/types.ts @@ -0,0 +1,11 @@ +export interface OAuthAttempt { + id: string + stateDigest: string + browserDigest: string + verifier: string + generation: number + credentialRevision: string + purpose: "sign-in" | "verify" | "enroll" + sessionId: string | null + expiresAt: Date +} diff --git a/hub/server/repositories/security-audits/constants.ts b/hub/server/repositories/security-audits/constants.ts new file mode 100644 index 00000000..2f80cb2e --- /dev/null +++ b/hub/server/repositories/security-audits/constants.ts @@ -0,0 +1,2 @@ +export const auditRetentionMs = 90 * 24 * 60 * 60_000 +export const auditResultLimit = 100 diff --git a/hub/server/repositories/security-audits/get-audits/index.ts b/hub/server/repositories/security-audits/get-audits/index.ts new file mode 100644 index 00000000..1b4a2e2c --- /dev/null +++ b/hub/server/repositories/security-audits/get-audits/index.ts @@ -0,0 +1,11 @@ +import { auditResultLimit, auditRetentionMs } from "#server/repositories/security-audits/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSecurityAudits(database: Prisma.TransactionClient) { + return database.securityAudit.findMany({ + where: { createdAt: { gte: new Date(Date.now() - auditRetentionMs) } }, + orderBy: { createdAt: "desc" }, + take: auditResultLimit, + }) +} diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts new file mode 100644 index 00000000..df32fdd1 --- /dev/null +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -0,0 +1,11 @@ +import { auditRetentionMs } from "#server/repositories/security-audits/constants" +import type { AuditInput } from "#server/repositories/security-audits/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveSecurityAudit(database: Prisma.TransactionClient, data: AuditInput) { + await database.securityAudit.deleteMany({ + where: { createdAt: { lt: new Date(Date.now() - auditRetentionMs) } }, + }) + await database.securityAudit.create({ data }) +} diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts new file mode 100644 index 00000000..ce9f5c5f --- /dev/null +++ b/hub/server/repositories/security-audits/types.ts @@ -0,0 +1,5 @@ +export interface AuditInput { + event: string + actorId: string | null + sessionId: string | null +} diff --git a/hub/server/repositories/sessions/create-session/index.ts b/hub/server/repositories/sessions/create-session/index.ts new file mode 100644 index 00000000..a5afcd10 --- /dev/null +++ b/hub/server/repositories/sessions/create-session/index.ts @@ -0,0 +1,7 @@ +import type { HubSession } from "#server/repositories/sessions/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function createSession(database: Prisma.TransactionClient, data: Omit) { + return database.session.create({ data }) +} diff --git a/hub/server/repositories/sessions/delete-session/index.ts b/hub/server/repositories/sessions/delete-session/index.ts new file mode 100644 index 00000000..5c1b0857 --- /dev/null +++ b/hub/server/repositories/sessions/delete-session/index.ts @@ -0,0 +1,6 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSession(database: Prisma.TransactionClient, id: string) { + await database.session.deleteMany({ where: { id } }) + await database.oAuthAttempt.deleteMany({ where: { sessionId: id } }) +} diff --git a/hub/server/repositories/sessions/delete-sessions/index.ts b/hub/server/repositories/sessions/delete-sessions/index.ts new file mode 100644 index 00000000..2d8f5fd1 --- /dev/null +++ b/hub/server/repositories/sessions/delete-sessions/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSessions(database: Prisma.TransactionClient) { + await database.session.deleteMany() +} diff --git a/hub/server/repositories/sessions/get-session-by-id/index.ts b/hub/server/repositories/sessions/get-session-by-id/index.ts new file mode 100644 index 00000000..c99dd229 --- /dev/null +++ b/hub/server/repositories/sessions/get-session-by-id/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSessionById(database: Prisma.TransactionClient, id: string) { + return database.session.findUnique({ where: { id }, include: { user: true } }) +} diff --git a/hub/server/repositories/sessions/get-session/index.ts b/hub/server/repositories/sessions/get-session/index.ts new file mode 100644 index 00000000..731c2e24 --- /dev/null +++ b/hub/server/repositories/sessions/get-session/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSession(database: Prisma.TransactionClient, tokenDigest: string) { + return database.session.findUnique({ where: { tokenDigest }, include: { user: true } }) +} diff --git a/hub/server/repositories/sessions/invalidate-freshness/index.ts b/hub/server/repositories/sessions/invalidate-freshness/index.ts new file mode 100644 index 00000000..9b106204 --- /dev/null +++ b/hub/server/repositories/sessions/invalidate-freshness/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function invalidateFreshness(database: Prisma.TransactionClient) { + await database.session.updateMany({ data: { freshAuthorizedAt: null } }) +} diff --git a/hub/server/repositories/sessions/types.ts b/hub/server/repositories/sessions/types.ts new file mode 100644 index 00000000..eb971187 --- /dev/null +++ b/hub/server/repositories/sessions/types.ts @@ -0,0 +1,8 @@ +export interface HubSession { + id: string + userId: string + tokenDigest: string + signedInAt: Date + freshAuthorizedAt: Date | null + credentialRevision: string +} diff --git a/hub/server/repositories/sessions/verify-session/index.ts b/hub/server/repositories/sessions/verify-session/index.ts new file mode 100644 index 00000000..a2b744f9 --- /dev/null +++ b/hub/server/repositories/sessions/verify-session/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function verifySession( + database: Prisma.TransactionClient, + id: string, + freshAuthorizedAt: Date, + credentialRevision: string, +) { + await database.session.update({ + where: { id }, + data: { freshAuthorizedAt, credentialRevision }, + }) +} diff --git a/hub/server/repositories/users/enroll-administrator/index.ts b/hub/server/repositories/users/enroll-administrator/index.ts new file mode 100644 index 00000000..8f7c84ed --- /dev/null +++ b/hub/server/repositories/users/enroll-administrator/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function enrollAdministrator(database: Prisma.TransactionClient, id: string) { + return database.user.update({ where: { id }, data: { administrator: true } }) +} diff --git a/hub/server/repositories/users/get-github-user/index.ts b/hub/server/repositories/users/get-github-user/index.ts new file mode 100644 index 00000000..033275b6 --- /dev/null +++ b/hub/server/repositories/users/get-github-user/index.ts @@ -0,0 +1,7 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getGitHubUser(database: Prisma.TransactionClient, providerId: string) { + return database.user.findUnique({ + where: { provider_providerId: { provider: "github", providerId } }, + }) +} diff --git a/hub/server/repositories/users/get-user/index.ts b/hub/server/repositories/users/get-user/index.ts new file mode 100644 index 00000000..956bc0e6 --- /dev/null +++ b/hub/server/repositories/users/get-user/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getUser(database: Prisma.TransactionClient, id: string) { + return database.user.findUnique({ where: { id } }) +} diff --git a/hub/server/repositories/users/save-github-user/index.ts b/hub/server/repositories/users/save-github-user/index.ts new file mode 100644 index 00000000..aee7135a --- /dev/null +++ b/hub/server/repositories/users/save-github-user/index.ts @@ -0,0 +1,11 @@ +import type { GitHubUserInput } from "#server/repositories/users/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function saveGitHubUser(database: Prisma.TransactionClient, profile: GitHubUserInput) { + return database.user.upsert({ + where: { provider_providerId: { provider: "github", providerId: profile.providerId } }, + create: { ...profile, provider: "github" }, + update: profile, + }) +} diff --git a/hub/server/repositories/users/types.ts b/hub/server/repositories/users/types.ts new file mode 100644 index 00000000..6f82d79e --- /dev/null +++ b/hub/server/repositories/users/types.ts @@ -0,0 +1,11 @@ +export interface HubUser { + id: string + provider: string + providerId: string + login: string + name: string | null + avatarUrl: string | null + administrator: boolean +} + +export type GitHubUserInput = Pick diff --git a/hub/tsconfig.json b/hub/tsconfig.json index 516108e7..fe11015c 100644 --- a/hub/tsconfig.json +++ b/hub/tsconfig.json @@ -8,6 +8,7 @@ "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "customConditions": ["development"], "noEmit": true, "noUncheckedIndexedAccess": true, "paths": { diff --git a/hub/tsconfig.server.json b/hub/tsconfig.server.json index ebd6babc..6c5d0765 100644 --- a/hub/tsconfig.server.json +++ b/hub/tsconfig.server.json @@ -12,5 +12,5 @@ "lib": ["ES2023"] }, "include": ["server/**/*.ts"], - "exclude": ["server/**/*.test.ts"] + "exclude": ["server/**/*.test.ts", "server/**/test/**"] } From b80ec6d329c03a006c227c4843808e59171e7603 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 12:20:38 -0700 Subject: [PATCH 2/5] feat(hub): establish provider-neutral identity persistence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 8 +- hub/docs/architecture.md | 2 +- hub/docs/backend-architecture.md | 4 +- hub/docs/database.md | 2 +- hub/docs/identity.md | 32 +- hub/docs/testing-and-operations.md | 4 +- hub/schema/authentication-provider.prisma | 42 ++ hub/schema/authorization-attempt.prisma | 27 ++ hub/schema/external-identity.prisma | 25 ++ ...prisma => github-integration-state.prisma} | 4 +- hub/schema/identity-store-state.prisma | 10 + hub/schema/installation-membership.prisma | 20 + .../20261026195151_identity/migration.sql | 172 ++++++-- hub/schema/oauth-attempt.prisma | 21 - hub/schema/security-audit.prisma | 7 +- hub/schema/session.prisma | 20 +- hub/schema/user.prisma | 13 +- .../create-credential-provider/index.test.ts | 38 +- .../create-credential-provider/index.ts | 14 +- .../create-credential-provider/types.ts | 8 + .../create-credential-provider/types.ts | 8 - .../domains/identity/create-records/index.ts | 38 +- .../identity/create-store/index.db.test.ts | 388 ++++++++++++++---- .../domains/identity/create-store/types.ts | 68 ++- .../domains/identity/with-store-lock/index.ts | 8 +- .../create-provider-revision/index.ts | 23 ++ .../authentication-providers/types.ts | 10 + .../consume-attempt/index.ts | 20 + .../create-attempt/index.ts | 11 + .../authorization-attempts/types.ts | 11 + .../get-external-identity/index.ts | 13 + .../save-external-identity/index.ts | 20 + .../repositories/external-identities/types.ts | 13 + .../advance-generation/index.ts | 11 + .../github-integration-state/constants.ts | 2 + .../ensure-state/index.ts | 11 + .../get-state/index.ts | 9 + .../save-credentials/index.ts | 14 + .../types.ts | 2 +- .../advance-generation/index.ts | 11 - .../repositories/identity-state/constants.ts | 2 - .../identity-state/get-state/index.ts | 7 - .../identity-state/save-credentials/index.ts | 13 - .../with-identity-lock/index.ts | 18 - .../identity-store-state/constants.ts | 2 + .../with-store-lock/index.ts | 18 + .../get-membership/index.ts | 5 + .../save-membership/index.ts | 15 + .../installation-memberships/types.ts | 14 + .../oauth-attempts/consume-attempt/index.ts | 20 - .../oauth-attempts/create-attempt/index.ts | 11 - .../repositories/oauth-attempts/types.ts | 11 - .../repositories/security-audits/types.ts | 3 +- .../sessions/create-session/index.ts | 12 +- .../sessions/delete-session/index.ts | 1 - .../sessions/get-session-by-id/index.ts | 9 +- .../sessions/get-session/index.ts | 9 +- hub/server/repositories/sessions/types.ts | 3 +- .../sessions/verify-session/index.ts | 3 +- .../repositories/users/create-user/index.ts | 7 + .../users/enroll-administrator/index.ts | 5 - .../users/get-github-user/index.ts | 7 - .../users/save-github-user/index.ts | 11 - hub/server/repositories/users/types.ts | 8 +- 64 files changed, 1011 insertions(+), 367 deletions(-) create mode 100644 hub/schema/authentication-provider.prisma create mode 100644 hub/schema/authorization-attempt.prisma create mode 100644 hub/schema/external-identity.prisma rename hub/schema/{identity-state.prisma => github-integration-state.prisma} (84%) create mode 100644 hub/schema/identity-store-state.prisma create mode 100644 hub/schema/installation-membership.prisma delete mode 100644 hub/schema/oauth-attempt.prisma rename hub/server/domains/{identity => github-integration}/create-credential-provider/index.test.ts (70%) rename hub/server/domains/{identity => github-integration}/create-credential-provider/index.ts (64%) create mode 100644 hub/server/domains/github-integration/create-credential-provider/types.ts delete mode 100644 hub/server/domains/identity/create-credential-provider/types.ts create mode 100644 hub/server/repositories/authentication-providers/create-provider-revision/index.ts create mode 100644 hub/server/repositories/authentication-providers/types.ts create mode 100644 hub/server/repositories/authorization-attempts/consume-attempt/index.ts create mode 100644 hub/server/repositories/authorization-attempts/create-attempt/index.ts create mode 100644 hub/server/repositories/authorization-attempts/types.ts create mode 100644 hub/server/repositories/external-identities/get-external-identity/index.ts create mode 100644 hub/server/repositories/external-identities/save-external-identity/index.ts create mode 100644 hub/server/repositories/external-identities/types.ts create mode 100644 hub/server/repositories/github-integration-state/advance-generation/index.ts create mode 100644 hub/server/repositories/github-integration-state/constants.ts create mode 100644 hub/server/repositories/github-integration-state/ensure-state/index.ts create mode 100644 hub/server/repositories/github-integration-state/get-state/index.ts create mode 100644 hub/server/repositories/github-integration-state/save-credentials/index.ts rename hub/server/repositories/{identity-state => github-integration-state}/types.ts (59%) delete mode 100644 hub/server/repositories/identity-state/advance-generation/index.ts delete mode 100644 hub/server/repositories/identity-state/constants.ts delete mode 100644 hub/server/repositories/identity-state/get-state/index.ts delete mode 100644 hub/server/repositories/identity-state/save-credentials/index.ts delete mode 100644 hub/server/repositories/identity-state/with-identity-lock/index.ts create mode 100644 hub/server/repositories/identity-store-state/constants.ts create mode 100644 hub/server/repositories/identity-store-state/with-store-lock/index.ts create mode 100644 hub/server/repositories/installation-memberships/get-membership/index.ts create mode 100644 hub/server/repositories/installation-memberships/save-membership/index.ts create mode 100644 hub/server/repositories/installation-memberships/types.ts delete mode 100644 hub/server/repositories/oauth-attempts/consume-attempt/index.ts delete mode 100644 hub/server/repositories/oauth-attempts/create-attempt/index.ts delete mode 100644 hub/server/repositories/oauth-attempts/types.ts create mode 100644 hub/server/repositories/users/create-user/index.ts delete mode 100644 hub/server/repositories/users/enroll-administrator/index.ts delete mode 100644 hub/server/repositories/users/get-github-user/index.ts delete mode 100644 hub/server/repositories/users/save-github-user/index.ts diff --git a/hub/.env.example b/hub/.env.example index 3dec67b0..699dbd50 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -14,13 +14,13 @@ DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica # Only database-enabled tests require this URL and CREATEDB permission. TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres -# Select the credential provider. See docs/identity.md. -HUB_CREDENTIAL_PROVIDER=database +# Select the GitHub source-integration credential provider. See docs/identity.md. +HUB_GITHUB_CREDENTIAL_PROVIDER=database # Generate separate values outside the database. See docs/identity.md. -# HUB_CREDENTIAL_KEY= +# HUB_GITHUB_CREDENTIAL_KEY= -# The environment provider is read-only and requires every GitHub setting below. +# The environment provider is read-only and requires every GitHub integration setting below. # GITHUB_APP_ID= # GITHUB_INSTALLATION_ID= # GITHUB_CLIENT_ID= diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 5363239e..80efd4ea 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. Identity models store credentials, users, sessions, sign-in attempts, and security records. [Credential providers](identity.md) use encrypted database values or read-only environment settings. Sign-in routes, 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 [identity foundation](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. Sign-in routes, deployment, and product APIs have not been built. This app cannot yet authenticate users, evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. ## Backend diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 1be8b7c7..4a410ae1 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -47,4 +47,6 @@ The [shared Prisma client](database.md#shared-client) lives in `server/integrati Compose repository functions in domains. The identity store binds standalone queries to a database client or transaction there; a repository must not import its neighboring queries to build that store. -`staticSettingsRecordId` identifies Hub's single settings row, not a person or GitHub installation. Its fixed UUID lets every process lock the same row. Keep it with the identity-state repository. +`staticIdentityStoreRecordId` identifies the row used to serialize identity-store transactions. It is not a user, provider configuration, or GitHub installation. Keep it with the identity-store state repository. + +GitHub source-integration credentials use a separate fixed state row. Authentication-provider configuration must not read or write that credential state. diff --git a/hub/docs/database.md b/hub/docs/database.md index 3f176ea1..386e4a56 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. The initial migration stores the [identity boundary](identity.md); product models have not been added. +Hub uses PostgreSQL and Prisma. The initial migration stores the provider-neutral [identity foundation](identity.md); product models have not been added. [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 9e0d375f..01e1e040 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,24 +1,34 @@ # Hub identity -Hub stores GitHub credentials, users, sessions, one-use sign-in attempts, and security records in PostgreSQL. Credential providers use one GitHub App installation. +Hub persists a provider-neutral identity foundation. User authentication proves an external identity. Hub installation membership decides whether that user belongs to this Hub and whether they are an administrator. Later authorization work will let Hub own prototype access independently of repository permissions. -## Credential providers +## Persisted boundaries -Set `HUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): +- `User` is the Hub-owned account. It does not contain a provider identifier or administrator flag. +- `ExternalIdentity` links a provider, issuer, and subject to one Hub user. One user may link several identities. The issuer is part of uniqueness so separate OIDC tenants may reuse subjects safely. +- `InstallationMembership` stores a user's status, role, and source for this Hub installation. +- `AuthenticationProvider` and its versioned revisions store provider-neutral authentication configuration. An OIDC-only configuration does not require GitHub source-integration credentials. +- `Session` records the Hub user, external identity, and authentication-provider revision that established it. GitHub source-integration credential rotation does not change session identity context. +- `AuthorizationAttempt` stores digested state and browser bindings, a PKCE verifier, an optional OIDC nonce, provider revision, purpose, optional originating session, and expiry. Consumption deletes the record in the same transaction so concurrent callbacks cannot reuse it. +- `SecurityAudit` stores the event and minimal optional identity context. Audit records do not create the future prototype authorization model. + +Store operations can share one database transaction and lock a fixed store-state row. Related records and audit events therefore commit or roll back together, and overlapping mutations observe committed state. + +## GitHub source-integration credentials + +GitHub source access is an optional integration boundary, not the global identity boundary. Set `HUB_GITHUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): | Provider | Credentials | | --- | --- | -| `database` (default) | Generate `HUB_CREDENTIAL_KEY` with `openssl rand -hex 32`. The provider encrypts credentials before storage and checks them when reading. | +| `database` (default) | Generate `HUB_GITHUB_CREDENTIAL_KEY` with `openssl rand -hex 32`. The provider encrypts GitHub integration credentials before storage and authenticates their context when reading. | | `environment` | Set every `GITHUB_*` value, including `GITHUB_INSTALLATION_ID`. This provider is read-only and ignores database credentials. | -Both providers require an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. - -Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors; Hub never falls back to another provider. Never commit credentials. +Both providers currently validate an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. -## Saved records +Database credentials use AES-256-GCM with contextual additional authenticated data. Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors; Hub never falls back to another provider. -Apply the committed migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. +## Foundation scope -Domain functions bind repository queries to a client or transaction. A fixed settings row lets overlapping changes lock the same record. Credential updates, session changes, and their audit records can commit together or roll back together. +This layer provides persistence only. It does not add HTTP sign-in routes, production OIDC or SCIM flows, prototype or product models, an authorization evaluator, GitHub permission synchronization, artifact ingestion, or UI. GitHub authentication configuration and GitHub source-integration credentials remain separate so either capability can change without redefining Hub identity. -User records use permanent GitHub IDs, not names or emails. Repositories consume each sign-in attempt once. Audit reads return the latest 100 records within 90 days; later writes remove older records. +Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 1ce32174..f15bfc29 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -34,7 +34,7 @@ Arrange each case's inputs and records inside that case. Act through its public Tests must release resources they create, such as servers, files, sockets, and event listeners. Use `try/finally` or register disposal with Vitest's `onTestFinished` when creating the resource. Clearing the module cache only affects subsequent imports. Reset any deliberately shared fixture state before each case. -Keep shared server test helpers under the owning function's `test/` directory. TypeScript checks them, but the API build excludes them. +Keep project-wide test helpers in the appropriate `test/` directory and function-specific helpers beside their owning function. TypeScript checks both, but the API build excludes them. ## Parallel database tests @@ -53,7 +53,7 @@ Use `.test.ts` or `.test.tsx` for ordinary cases. Use `.db.test.ts` or `.db.test Start the PostgreSQL service as described in [Development](development.md) and set `TEST_DATABASE_URL` using `.env.example`. The devcontainer supplies this variable. Use a local database user with permission to create and drop databases, never a production connection. -The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise persistence, one-use attempts, transaction rollback, and concurrent revocation locks. Job integration cases exercise real PostgreSQL queues, schedules, and database resets. +The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise external identity linking and uniqueness, installation membership, provider-aware one-use attempts, session context, GitHub integration credential independence, transaction rollback, and concurrent store locks. Job integration cases exercise real PostgreSQL queues, schedules, and database resets. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. diff --git a/hub/schema/authentication-provider.prisma b/hub/schema/authentication-provider.prisma new file mode 100644 index 00000000..4e9b4a32 --- /dev/null +++ b/hub/schema/authentication-provider.prisma @@ -0,0 +1,42 @@ +model AuthenticationProvider { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + revisions AuthenticationProviderRevision[] + + // Attribute columns + key String @unique + type String + enabled Boolean @default(true) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("authentication_providers") +} + +model AuthenticationProviderRevision { + // ID columns + id String @id @default(uuid()) @db.Uuid + authenticationProviderId String @map("authentication_provider_id") @db.Uuid + + // Relations + authenticationProvider AuthenticationProvider @relation(fields: [authenticationProviderId], references: [id], onDelete: Cascade) + authorizationAttempts AuthorizationAttempt[] + sessions Session[] + + // Attribute columns + revision Int + issuer String + configuration Json + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([authenticationProviderId, revision]) + @@index([issuer]) + @@map("authentication_provider_revisions") +} diff --git a/hub/schema/authorization-attempt.prisma b/hub/schema/authorization-attempt.prisma new file mode 100644 index 00000000..f3743ace --- /dev/null +++ b/hub/schema/authorization-attempt.prisma @@ -0,0 +1,27 @@ +model AuthorizationAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + authenticationProviderRevisionId String @map("authentication_provider_revision_id") @db.Uuid + originatingSessionId String? @map("originating_session_id") @db.Uuid + + // Relations + authenticationProviderRevision AuthenticationProviderRevision @relation(fields: [authenticationProviderRevisionId], references: [id], onDelete: Cascade) + originatingSession Session? @relation("OriginatingSession", fields: [originatingSessionId], references: [id], onDelete: Cascade) + + // Attribute columns + stateDigest String @unique @map("state_digest") + browserDigest String @map("browser_digest") + pkceVerifier String @map("pkce_verifier") + nonce String? + purpose String + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([expiresAt]) + @@index([authenticationProviderRevisionId]) + @@index([originatingSessionId]) + @@map("authorization_attempts") +} diff --git a/hub/schema/external-identity.prisma b/hub/schema/external-identity.prisma new file mode 100644 index 00000000..908e4cfa --- /dev/null +++ b/hub/schema/external-identity.prisma @@ -0,0 +1,25 @@ +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: Cascade) + sessions Session[] + + // Attribute columns + provider String + issuer String + subject String + username String? + displayName String? @map("display_name") + avatarUrl String? @map("avatar_url") + + // 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/identity-state.prisma b/hub/schema/github-integration-state.prisma similarity index 84% rename from hub/schema/identity-state.prisma rename to hub/schema/github-integration-state.prisma index 77757ab5..7349ad5a 100644 --- a/hub/schema/identity-state.prisma +++ b/hub/schema/github-integration-state.prisma @@ -1,4 +1,4 @@ -model IdentityState { +model GitHubIntegrationState { // ID columns id String @id @db.Uuid @@ -10,5 +10,5 @@ model IdentityState { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) - @@map("identity_states") + @@map("github_integration_states") } diff --git a/hub/schema/identity-store-state.prisma b/hub/schema/identity-store-state.prisma new file mode 100644 index 00000000..06db17be --- /dev/null +++ b/hub/schema/identity-store-state.prisma @@ -0,0 +1,10 @@ +model IdentityStoreState { + // ID columns + id String @id @db.Uuid + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("identity_store_states") +} diff --git a/hub/schema/installation-membership.prisma b/hub/schema/installation-membership.prisma new file mode 100644 index 00000000..da4ea594 --- /dev/null +++ b/hub/schema/installation-membership.prisma @@ -0,0 +1,20 @@ +model InstallationMembership { + // 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: Cascade) + + // Attribute columns + status String + role String + source String + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([userId]) + @@map("installation_memberships") +} diff --git a/hub/schema/migrations/20261026195151_identity/migration.sql b/hub/schema/migrations/20261026195151_identity/migration.sql index ec013404..025776e7 100644 --- a/hub/schema/migrations/20261026195151_identity/migration.sql +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -1,51 +1,100 @@ -- CreateTable -CREATE TABLE "identity_states" ( +CREATE TABLE "authentication_providers" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "type" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "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") +); + +-- CreateTable +CREATE TABLE "authentication_provider_revisions" ( + "id" UUID NOT NULL, + "authentication_provider_id" UUID NOT NULL, + "revision" INTEGER NOT NULL, + "issuer" TEXT NOT NULL, + "configuration" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_provider_revisions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "identity_store_states" ( + "id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "identity_store_states_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "github_integration_states" ( "id" UUID NOT NULL, "generation" INTEGER NOT NULL DEFAULT 0, "encrypted_credentials" TEXT, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "identity_states_pkey" PRIMARY KEY ("id") + CONSTRAINT "github_integration_states_pkey" PRIMARY KEY ("id") ); +-- SeedData +INSERT INTO "github_integration_states" ("id") VALUES ('00000000-0000-4000-8000-000000000001'); + -- CreateTable -CREATE TABLE "oauth_attempts" ( +CREATE TABLE "users" ( "id" UUID NOT NULL, - "session_id" UUID, - "state_digest" TEXT NOT NULL, - "browser_digest" TEXT NOT NULL, - "verifier" TEXT NOT NULL, - "generation" INTEGER NOT NULL, - "credential_revision" TEXT NOT NULL, - "purpose" TEXT NOT NULL, - "expires_at" TIMESTAMPTZ(3) NOT NULL, + "display_name" TEXT, + "avatar_url" TEXT, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "oauth_attempts_pkey" PRIMARY KEY ("id") + CONSTRAINT "users_pkey" PRIMARY KEY ("id") ); -- CreateTable -CREATE TABLE "security_audits" ( +CREATE TABLE "external_identities" ( "id" UUID NOT NULL, - "actor_id" UUID, - "session_id" UUID, - "event" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "username" TEXT, + "display_name" TEXT, + "avatar_url" TEXT, "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 "external_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "installation_memberships" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "status" TEXT NOT NULL, + "role" TEXT NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "installation_memberships_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "sessions" ( "id" UUID NOT NULL, "user_id" UUID NOT NULL, + "external_identity_id" UUID NOT NULL, + "authentication_provider_revision_id" UUID NOT NULL, "token_digest" TEXT NOT NULL, "signed_in_at" TIMESTAMPTZ(3) NOT NULL, "fresh_authorized_at" TIMESTAMPTZ(3), - "credential_revision" TEXT NOT NULL, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -53,28 +102,52 @@ CREATE TABLE "sessions" ( ); -- CreateTable -CREATE TABLE "users" ( +CREATE TABLE "authorization_attempts" ( "id" UUID NOT NULL, - "provider" TEXT NOT NULL DEFAULT 'github', - "provider_id" TEXT NOT NULL, - "login" TEXT NOT NULL, - "name" TEXT, - "avatar_url" TEXT, - "administrator" BOOLEAN NOT NULL DEFAULT false, + "authentication_provider_revision_id" UUID NOT NULL, + "originating_session_id" UUID, + "state_digest" TEXT NOT NULL, + "browser_digest" TEXT NOT NULL, + "pkce_verifier" TEXT NOT NULL, + "nonce" TEXT, + "purpose" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) 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") + CONSTRAINT "authorization_attempts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "security_audits" ( + "id" UUID NOT NULL, + "actor_user_id" UUID, + "session_id" UUID, + "authentication_provider_revision_id" UUID, + "event" TEXT 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") ); -- CreateIndex -CREATE UNIQUE INDEX "oauth_attempts_state_digest_key" ON "oauth_attempts"("state_digest"); +CREATE UNIQUE INDEX "authentication_providers_key_key" ON "authentication_providers"("key"); -- CreateIndex -CREATE INDEX "oauth_attempts_expires_at_idx" ON "oauth_attempts"("expires_at"); +CREATE INDEX "authentication_provider_revisions_issuer_idx" ON "authentication_provider_revisions"("issuer"); -- CreateIndex -CREATE INDEX "security_audits_created_at_idx" ON "security_audits"("created_at"); +CREATE UNIQUE INDEX "authentication_provider_revisions_authentication_provider_i_key" ON "authentication_provider_revisions"("authentication_provider_id", "revision"); + +-- CreateIndex +CREATE INDEX "external_identities_user_id_idx" ON "external_identities"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "external_identities_provider_issuer_subject_key" ON "external_identities"("provider", "issuer", "subject"); + +-- CreateIndex +CREATE UNIQUE INDEX "installation_memberships_user_id_key" ON "installation_memberships"("user_id"); -- CreateIndex CREATE UNIQUE INDEX "sessions_token_digest_key" ON "sessions"("token_digest"); @@ -83,7 +156,46 @@ CREATE UNIQUE INDEX "sessions_token_digest_key" ON "sessions"("token_digest"); CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); -- CreateIndex -CREATE UNIQUE INDEX "users_provider_provider_id_key" ON "users"("provider", "provider_id"); +CREATE INDEX "sessions_external_identity_id_idx" ON "sessions"("external_identity_id"); + +-- CreateIndex +CREATE INDEX "sessions_authentication_provider_revision_id_idx" ON "sessions"("authentication_provider_revision_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "authorization_attempts_state_digest_key" ON "authorization_attempts"("state_digest"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_expires_at_idx" ON "authorization_attempts"("expires_at"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_authentication_provider_revision_id_idx" ON "authorization_attempts"("authentication_provider_revision_id"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_originating_session_id_idx" ON "authorization_attempts"("originating_session_id"); + +-- CreateIndex +CREATE INDEX "security_audits_created_at_idx" ON "security_audits"("created_at"); + +-- AddForeignKey +ALTER TABLE "authentication_provider_revisions" ADD CONSTRAINT "authentication_provider_revisions_authentication_provider__fkey" FOREIGN KEY ("authentication_provider_id") REFERENCES "authentication_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "external_identities" ADD CONSTRAINT "external_identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_external_identity_id_fkey" FOREIGN KEY ("external_identity_id") REFERENCES "external_identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_authentication_provider_revision_id_fkey" FOREIGN KEY ("authentication_provider_revision_id") REFERENCES "authentication_provider_revisions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "authorization_attempts" ADD CONSTRAINT "authorization_attempts_authentication_provider_revision_id_fkey" FOREIGN KEY ("authentication_provider_revision_id") REFERENCES "authentication_provider_revisions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "authorization_attempts" ADD CONSTRAINT "authorization_attempts_originating_session_id_fkey" FOREIGN KEY ("originating_session_id") REFERENCES "sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/schema/oauth-attempt.prisma b/hub/schema/oauth-attempt.prisma deleted file mode 100644 index 6495ce32..00000000 --- a/hub/schema/oauth-attempt.prisma +++ /dev/null @@ -1,21 +0,0 @@ -model OAuthAttempt { - // ID columns - id String @id @default(uuid()) @db.Uuid - sessionId String? @map("session_id") @db.Uuid - - // Attribute columns - stateDigest String @unique @map("state_digest") - browserDigest String @map("browser_digest") - verifier String - generation Int - credentialRevision String @map("credential_revision") - purpose String - expiresAt DateTime @map("expires_at") @db.Timestamptz(3) - - // Timestamp columns - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) - - @@index([expiresAt]) - @@map("oauth_attempts") -} diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index 1496c4bf..8d46e5e8 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -1,8 +1,9 @@ model SecurityAudit { // ID columns - id String @id @default(uuid()) @db.Uuid - actorId String? @map("actor_id") @db.Uuid - sessionId String? @map("session_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + actorUserId String? @map("actor_user_id") @db.Uuid + sessionId String? @map("session_id") @db.Uuid + authenticationProviderRevisionId String? @map("authentication_provider_revision_id") @db.Uuid // Attribute columns event String diff --git a/hub/schema/session.prisma b/hub/schema/session.prisma index 60738974..bd8a5d29 100644 --- a/hub/schema/session.prisma +++ b/hub/schema/session.prisma @@ -1,21 +1,27 @@ model Session { // ID columns - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + externalIdentityId String @map("external_identity_id") @db.Uuid + authenticationProviderRevisionId String @map("authentication_provider_revision_id") @db.Uuid // Relations - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + externalIdentity ExternalIdentity @relation(fields: [externalIdentityId], references: [id], onDelete: Restrict) + authenticationProviderRevision AuthenticationProviderRevision @relation(fields: [authenticationProviderRevisionId], references: [id], onDelete: Restrict) + originatingAuthorizationAttempts AuthorizationAttempt[] @relation("OriginatingSession") // Attribute columns - tokenDigest String @unique @map("token_digest") - signedInAt DateTime @map("signed_in_at") @db.Timestamptz(3) - freshAuthorizedAt DateTime? @map("fresh_authorized_at") @db.Timestamptz(3) - credentialRevision String @map("credential_revision") + tokenDigest String @unique @map("token_digest") + signedInAt DateTime @map("signed_in_at") @db.Timestamptz(3) + freshAuthorizedAt DateTime? @map("fresh_authorized_at") @db.Timestamptz(3) // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) @@index([userId]) + @@index([externalIdentityId]) + @@index([authenticationProviderRevisionId]) @@map("sessions") } diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma index 39a567d5..73ed59e8 100644 --- a/hub/schema/user.prisma +++ b/hub/schema/user.prisma @@ -3,20 +3,17 @@ model User { id String @id @default(uuid()) @db.Uuid // Relations - sessions Session[] + externalIdentities ExternalIdentity[] + memberships InstallationMembership[] + sessions Session[] // Attribute columns - provider String @default("github") - providerId String @map("provider_id") - login String - name String? - avatarUrl String? @map("avatar_url") - administrator Boolean @default(false) + displayName String? @map("display_name") + avatarUrl String? @map("avatar_url") // 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, providerId]) @@map("users") } diff --git a/hub/server/domains/identity/create-credential-provider/index.test.ts b/hub/server/domains/github-integration/create-credential-provider/index.test.ts similarity index 70% rename from hub/server/domains/identity/create-credential-provider/index.test.ts rename to hub/server/domains/github-integration/create-credential-provider/index.test.ts index 05f34d2e..c3a7c6ee 100644 --- a/hub/server/domains/identity/create-credential-provider/index.test.ts +++ b/hub/server/domains/github-integration/create-credential-provider/index.test.ts @@ -1,7 +1,7 @@ import { generateKeyPairSync } from "node:crypto" import { describe, expect, it } from "vitest" -import { createCredentialProvider } from "#server/domains/identity/create-credential-provider" +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" function credentials() { return { @@ -17,9 +17,11 @@ function credentials() { } } -describe("createCredentialProvider()", () => { +describe("createGitHubIntegrationCredentialProvider()", () => { it("starts unconfigured and encrypts database credentials with fresh authenticated ciphertext", () => { - const provider = createCredentialProvider({ HUB_CREDENTIAL_KEY: "ab".repeat(32) }) + const provider = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) const input = credentials() expect(provider.name).toBe("database") @@ -35,20 +37,24 @@ describe("createCredentialProvider()", () => { expect(() => provider.read({ generation: 0, encryptedCredentials: tampered.toString("base64") }), ).toThrow() - const otherKey = createCredentialProvider({ HUB_CREDENTIAL_KEY: "cd".repeat(32) }) + const otherKey = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_KEY: "cd".repeat(32), + }) expect(() => otherKey.read({ generation: 0, encryptedCredentials: encrypted })).toThrow() }) it("requires a valid encryption key outside the database", () => { for (const key of [undefined, "ab".repeat(31), "zz".repeat(32)]) { - expect(() => createCredentialProvider({ HUB_CREDENTIAL_KEY: key })).toThrow() + expect(() => + createGitHubIntegrationCredentialProvider({ HUB_GITHUB_CREDENTIAL_KEY: key }), + ).toThrow() } }) it("uses complete environment credentials read-only without consulting database ciphertext", () => { const input = credentials() - const provider = createCredentialProvider({ - HUB_CREDENTIAL_PROVIDER: "environment", + const provider = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", GITHUB_APP_ID: input.appId, GITHUB_INSTALLATION_ID: input.installationId, GITHUB_CLIENT_ID: input.clientId, @@ -66,8 +72,8 @@ describe("createCredentialProvider()", () => { it("rejects incomplete environment configuration and unknown providers instead of falling back", () => { const input = credentials() const environment = { - HUB_CREDENTIAL_PROVIDER: "environment", - HUB_CREDENTIAL_KEY: "ab".repeat(32), + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), GITHUB_APP_ID: input.appId, GITHUB_INSTALLATION_ID: input.installationId, GITHUB_CLIENT_ID: input.clientId, @@ -81,13 +87,21 @@ describe("createCredentialProvider()", () => { "GITHUB_CLIENT_SECRET", "GITHUB_PRIVATE_KEY", ] as const) { - expect(() => createCredentialProvider({ ...environment, [field]: undefined })).toThrow() + expect(() => + createGitHubIntegrationCredentialProvider({ ...environment, [field]: undefined }), + ).toThrow() } expect(() => - createCredentialProvider({ ...environment, GITHUB_PRIVATE_KEY: "invalid" }), + createGitHubIntegrationCredentialProvider({ + ...environment, + GITHUB_PRIVATE_KEY: "invalid", + }), ).toThrow() expect(() => - createCredentialProvider({ ...environment, HUB_CREDENTIAL_PROVIDER: "other" }), + createGitHubIntegrationCredentialProvider({ + ...environment, + HUB_GITHUB_CREDENTIAL_PROVIDER: "other", + }), ).toThrow() }) }) diff --git a/hub/server/domains/identity/create-credential-provider/index.ts b/hub/server/domains/github-integration/create-credential-provider/index.ts similarity index 64% rename from hub/server/domains/identity/create-credential-provider/index.ts rename to hub/server/domains/github-integration/create-credential-provider/index.ts index 3b6f661e..c63c01dd 100644 --- a/hub/server/domains/identity/create-credential-provider/index.ts +++ b/hub/server/domains/github-integration/create-credential-provider/index.ts @@ -1,10 +1,12 @@ import { decryptGitHubCredentials } from "#server/integrations/github/decrypt-github-credentials" import { encryptGitHubCredentials } from "#server/integrations/github/encrypt-github-credentials" import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" -import type { CredentialProvider } from "#server/domains/identity/create-credential-provider/types" +import type { GitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider/types" -export function createCredentialProvider(environment: NodeJS.ProcessEnv): CredentialProvider { - const selected = environment.HUB_CREDENTIAL_PROVIDER ?? "database" +export function createGitHubIntegrationCredentialProvider( + environment: NodeJS.ProcessEnv, +): GitHubIntegrationCredentialProvider { + const selected = environment.HUB_GITHUB_CREDENTIAL_PROVIDER ?? "database" if (selected === "environment") { const credentials = validateGitHubCredentials({ appId: environment.GITHUB_APP_ID, @@ -16,13 +18,13 @@ export function createCredentialProvider(environment: NodeJS.ProcessEnv): Creden return { name: "environment", read: () => credentials } } if (selected !== "database") { - throw new Error("HUB_CREDENTIAL_PROVIDER must be database or environment.") + throw new Error("HUB_GITHUB_CREDENTIAL_PROVIDER must be database or environment.") } - const encodedKey = environment.HUB_CREDENTIAL_KEY + const encodedKey = environment.HUB_GITHUB_CREDENTIAL_KEY if (!encodedKey || !/^[a-fA-F0-9]{64}$/.test(encodedKey)) { throw new Error( - "Set HUB_CREDENTIAL_KEY to a 32-byte hexadecimal encryption key outside the database.", + "Set HUB_GITHUB_CREDENTIAL_KEY to a 32-byte hexadecimal encryption key outside the database.", ) } const key = Buffer.from(encodedKey, "hex") diff --git a/hub/server/domains/github-integration/create-credential-provider/types.ts b/hub/server/domains/github-integration/create-credential-provider/types.ts new file mode 100644 index 00000000..ee46be51 --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/types.ts @@ -0,0 +1,8 @@ +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" + +export interface GitHubIntegrationCredentialProvider { + name: "database" | "environment" + read(state: GitHubIntegrationState): GitHubCredentials | null + encrypt?: (credentials: GitHubCredentials) => string +} diff --git a/hub/server/domains/identity/create-credential-provider/types.ts b/hub/server/domains/identity/create-credential-provider/types.ts deleted file mode 100644 index db294218..00000000 --- a/hub/server/domains/identity/create-credential-provider/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" -import type { IdentityState } from "#server/repositories/identity-state/types" - -export interface CredentialProvider { - name: "database" | "environment" - read(state: IdentityState): GitHubCredentials | null - encrypt?: (credentials: GitHubCredentials) => string -} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 81e7074c..82411d9c 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -1,8 +1,13 @@ -import { advanceIdentityGeneration } from "#server/repositories/identity-state/advance-generation" -import { getIdentityState } from "#server/repositories/identity-state/get-state" -import { saveIdentityCredentials } from "#server/repositories/identity-state/save-credentials" -import { consumeOAuthAttempt } from "#server/repositories/oauth-attempts/consume-attempt" -import { createOAuthAttempt } from "#server/repositories/oauth-attempts/create-attempt" +import { createAuthenticationProviderRevision } from "#server/repositories/authentication-providers/create-provider-revision" +import { consumeAuthorizationAttempt } from "#server/repositories/authorization-attempts/consume-attempt" +import { createAuthorizationAttempt } from "#server/repositories/authorization-attempts/create-attempt" +import { getExternalIdentity } from "#server/repositories/external-identities/get-external-identity" +import { saveExternalIdentity } from "#server/repositories/external-identities/save-external-identity" +import { advanceGitHubIntegrationGeneration } from "#server/repositories/github-integration-state/advance-generation" +import { getGitHubIntegrationState } from "#server/repositories/github-integration-state/get-state" +import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-state/save-credentials" +import { getInstallationMembership } from "#server/repositories/installation-memberships/get-membership" +import { saveInstallationMembership } from "#server/repositories/installation-memberships/save-membership" import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" import { createSession } from "#server/repositories/sessions/create-session" @@ -12,23 +17,24 @@ import { getSession } from "#server/repositories/sessions/get-session" import { getSessionById } from "#server/repositories/sessions/get-session-by-id" import { invalidateFreshness } from "#server/repositories/sessions/invalidate-freshness" import { verifySession } from "#server/repositories/sessions/verify-session" -import { enrollAdministrator } from "#server/repositories/users/enroll-administrator" -import { getGitHubUser } from "#server/repositories/users/get-github-user" +import { createUser } from "#server/repositories/users/create-user" import { getUser } from "#server/repositories/users/get-user" -import { saveGitHubUser } from "#server/repositories/users/save-github-user" import type { IdentityRecords } from "#server/domains/identity/create-store/types" import type { Prisma } from "../../../generated/prisma/client.ts" export function createIdentityRecords(database: Prisma.TransactionClient): IdentityRecords { return { - state: getIdentityState.bind(null, database), - saveCredentials: saveIdentityCredentials.bind(null, database), - advanceGeneration: advanceIdentityGeneration.bind(null, database), + githubIntegrationState: getGitHubIntegrationState.bind(null, database), + saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), + advanceGitHubIntegrationGeneration: advanceGitHubIntegrationGeneration.bind(null, database), user: getUser.bind(null, database), - githubUser: getGitHubUser.bind(null, database), - saveUser: saveGitHubUser.bind(null, database), - enroll: enrollAdministrator.bind(null, database), + createUser: createUser.bind(null, database), + externalIdentity: getExternalIdentity.bind(null, database), + saveExternalIdentity: saveExternalIdentity.bind(null, database), + membership: getInstallationMembership.bind(null, database), + saveMembership: saveInstallationMembership.bind(null, database), + createAuthenticationProviderRevision: createAuthenticationProviderRevision.bind(null, database), session: getSession.bind(null, database), sessionById: getSessionById.bind(null, database), createSession: createSession.bind(null, database), @@ -36,8 +42,8 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident invalidateFreshness: invalidateFreshness.bind(null, database), deleteSession: deleteSession.bind(null, database), deleteSessions: deleteSessions.bind(null, database), - createAttempt: createOAuthAttempt.bind(null, database), - consumeAttempt: consumeOAuthAttempt.bind(null, database), + createAuthorizationAttempt: createAuthorizationAttempt.bind(null, database), + consumeAuthorizationAttempt: consumeAuthorizationAttempt.bind(null, database), audit: saveSecurityAudit.bind(null, database), audits: getSecurityAudits.bind(null, database), } diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts index 07b66171..b5eb4c51 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createIdentityStore } from "#server/domains/identity/create-store" +type IdentityStore = ReturnType + async function database() { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") @@ -9,41 +11,79 @@ async function database() { return { prisma, store: createIdentityStore(prisma) } } +async function createIdentityGraph(store: IdentityStore, suffix: string) { + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `Synthetic User ${suffix}`, + avatarUrl: null, + }) + const externalIdentity = await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: `https://issuer-${suffix}.example`, + subject: `subject-${suffix}`, + username: `synthetic-${suffix}`, + displayName: `Synthetic User ${suffix}`, + avatarUrl: null, + }) + const authenticationProviderRevision = await records.createAuthenticationProviderRevision({ + key: `oidc-${suffix}`, + type: "oidc", + enabled: true, + revision: 1, + issuer: `https://issuer-${suffix}.example`, + configuration: { + authorizationEndpoint: `https://issuer-${suffix}.example/authorize`, + clientId: `client-${suffix}`, + }, + }) + return { authenticationProviderRevision, externalIdentity, user } + }) +} + +async function createSession(store: IdentityStore, suffix: string) { + const graph = await createIdentityGraph(store, suffix) + const session = await store.lock((records) => + records.createSession({ + userId: graph.user.id, + externalIdentityId: graph.externalIdentity.id, + authenticationProviderRevisionId: graph.authenticationProviderRevision.id, + tokenDigest: `token-digest-${suffix}`, + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ) + return { ...graph, session } +} + describe("createIdentityStore()", () => { - it("rolls back credential, session, generation, and audit changes together", async () => { + it("rolls back GitHub integration, session, and audit changes together", async () => { const { store } = await database() - await store.lock(async (records) => { - const user = await records.saveUser({ - providerId: "10001", - login: "synthetic-user", - name: null, - avatarUrl: null, - }) - await records.saveCredentials("initial-encrypted-fixture") - await records.createSession({ - userId: user.id, - tokenDigest: "synthetic-token-digest", - signedInAt: new Date(), - freshAuthorizedAt: new Date(), - credentialRevision: "initial-revision", - }) - }) + const { session } = await createSession(store, "rollback") + await store.lock((records) => + records.saveGitHubIntegrationCredentials("initial-encrypted-fixture"), + ) await expect( store.lock(async (records) => { - await records.saveCredentials("replacement-encrypted-fixture") - await records.advanceGeneration() + await records.saveGitHubIntegrationCredentials("replacement-encrypted-fixture") + await records.advanceGitHubIntegrationGeneration() await records.deleteSessions() - await records.audit({ event: "session.revoke_all", actorId: null, sessionId: null }) + await records.audit({ + event: "session.revoke_all", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + }) throw new Error("Transaction failed") }), ).rejects.toThrow("Transaction failed") - expect(await store.read.state()).toMatchObject({ + expect(await store.read.githubIntegrationState()).toMatchObject({ generation: 0, encryptedCredentials: "initial-encrypted-fixture", }) - expect(await store.read.session("synthetic-token-digest")).not.toBeNull() + expect(await store.read.sessionById(session.id)).not.toBeNull() expect(await store.read.audits()).toEqual([]) }) @@ -59,106 +99,270 @@ describe("createIdentityStore()", () => { release = resolve }) const first = store.lock(async (records) => { - await records.advanceGeneration() + await records.advanceGitHubIntegrationGeneration() entered() await gate }) await started const second = store.lock(async (records) => { - const state = await records.state() - await records.advanceGeneration() + const state = await records.githubIntegrationState() + await records.advanceGitHubIntegrationGeneration() return state.generation }) release() await first expect(await second).toBe(1) - expect((await store.read.state()).generation).toBe(2) + expect((await store.read.githubIntegrationState()).generation).toBe(2) }) - it("upserts by immutable GitHub account rather than a reused login", async () => { + it("links multiple issuer-scoped external identities without allowing a relink", async () => { const { store } = await database() - const profile = { providerId: "10001", login: "synthetic-user", name: null, avatarUrl: null } - const original = await store.lock((records) => records.saveUser(profile)) - await store.lock((records) => records.enroll(original.id)) - const renamed = await store.lock((records) => - records.saveUser({ ...profile, login: "renamed-user" }), + const firstUser = await store.lock((records) => + records.createUser({ displayName: "First user", avatarUrl: null }), ) - const reused = await store.lock((records) => - records.saveUser({ ...profile, providerId: "20002" }), + const secondUser = await store.lock((records) => + records.createUser({ displayName: "Second user", avatarUrl: null }), + ) + const firstIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: firstUser.id, + provider: "oidc", + issuer: "https://first.example", + subject: "shared-subject", + username: "first-name", + displayName: "First identity", + avatarUrl: null, + }), + ) + const secondIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: firstUser.id, + provider: "oidc", + issuer: "https://second.example", + subject: "shared-subject", + username: "second-name", + displayName: "Second identity", + avatarUrl: null, + }), + ) + const relinkAttempt = await store.lock((records) => + records.saveExternalIdentity({ + userId: secondUser.id, + provider: "oidc", + issuer: "https://first.example", + subject: "shared-subject", + username: "updated-name", + displayName: "Updated identity", + avatarUrl: null, + }), ) - expect(renamed.id).toBe(original.id) - expect(renamed.administrator).toBe(true) - expect(reused.id).not.toBe(original.id) - expect(reused.administrator).toBe(false) + expect(secondIdentity.id).not.toBe(firstIdentity.id) + expect(secondIdentity.userId).toBe(firstUser.id) + expect(relinkAttempt).toMatchObject({ + id: firstIdentity.id, + userId: firstUser.id, + username: "updated-name", + }) + expect( + await store.read.externalIdentity("oidc", "https://first.example", "shared-subject"), + ).toMatchObject({ id: firstIdentity.id, user: { id: firstUser.id } }) + expect(await store.read.user(firstUser.id)).not.toHaveProperty("administrator") }) - it("consumes an OAuth attempt once across concurrent transactions and clears pending attempts on a cutoff", async () => { + it("persists installation status, role, and source independently from the user", async () => { const { store } = await database() - const input = { - stateDigest: "synthetic-state-digest", - browserDigest: "synthetic-browser-digest", - verifier: "synthetic-verifier", + const user = await store.lock((records) => + records.createUser({ displayName: "Administrator", avatarUrl: null }), + ) + + await store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "active", + role: "administrator", + source: "bootstrap", + }), + ) + await store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + }), + ) + + expect(await store.read.membership(user.id)).toMatchObject({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + }) + expect(await store.read.user(user.id)).toEqual(user) + expect(user).not.toHaveProperty("administrator") + }) + + it("stores OIDC provider revisions separately from GitHub integration credentials", async () => { + const { prisma, store } = await database() + const revision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "workforce-oidc", + type: "oidc", + enabled: true, + revision: 7, + issuer: "https://login.example", + configuration: { + authorizationEndpoint: "https://login.example/authorize", + clientId: "replica-hub", + }, + }), + ) + + expect(revision).toMatchObject({ + revision: 7, + issuer: "https://login.example", + configuration: { + authorizationEndpoint: "https://login.example/authorize", + clientId: "replica-hub", + }, + authenticationProvider: { + key: "workforce-oidc", + type: "oidc", + enabled: true, + }, + }) + expect(await store.read.githubIntegrationState()).toMatchObject({ generation: 0, - credentialRevision: "synthetic-revision", + encryptedCredentials: null, + }) + + await expect( + store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "workforce-oidc", + type: "oidc", + enabled: true, + revision: 7, + issuer: "https://replacement.example", + configuration: { clientId: "replacement-client" }, + }), + ), + ).rejects.toThrow() + await store.lock((records) => + records.saveGitHubIntegrationCredentials("encrypted-github-credentials"), + ) + + expect( + await prisma.authenticationProviderRevision.findUnique({ where: { id: revision.id } }), + ).toMatchObject({ + issuer: "https://login.example", + configuration: revision.configuration, + }) + }) + + it("keeps sessions tied to identity revisions when GitHub credentials rotate", async () => { + const { store } = await database() + const { authenticationProviderRevision, externalIdentity, session, user } = await createSession( + store, + "credential-rotation", + ) + + await store.lock(async (records) => { + await records.saveGitHubIntegrationCredentials("rotated-encrypted-credentials") + await records.advanceGitHubIntegrationGeneration() + }) + + expect(await store.read.session(session.tokenDigest)).toMatchObject({ + id: session.id, + user: { id: user.id }, + externalIdentity: { id: externalIdentity.id }, + authenticationProviderRevision: { + id: authenticationProviderRevision.id, + authenticationProvider: { key: "oidc-credential-rotation", type: "oidc" }, + }, + }) + }) + + it("prunes expired provider-aware attempts and consumes a live attempt once", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision, session } = await createSession(store, "attempt") + const otherProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "other-oidc", + type: "oidc", + enabled: true, + revision: 2, + issuer: "https://other.example", + configuration: { clientId: "other-client" }, + }), + ) + const expired = { + authenticationProviderRevisionId: otherProviderRevision.id, + originatingSessionId: null, + stateDigest: "expired-state-digest", + browserDigest: "expired-browser-digest", + pkceVerifier: "expired-pkce-verifier", + nonce: null, purpose: "sign-in" as const, - sessionId: null, + expiresAt: new Date(Date.now() - 1), + } + const live = { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: session.id, + stateDigest: "live-state-digest", + browserDigest: "live-browser-digest", + pkceVerifier: "live-pkce-verifier", + nonce: "live-oidc-nonce", + purpose: "link-identity" as const, expiresAt: new Date(Date.now() + 15 * 60_000), } - await store.lock((records) => records.createAttempt(input)) + await store.lock((records) => records.createAuthorizationAttempt(expired)) + await store.lock((records) => records.createAuthorizationAttempt(live)) + + expect( + await prisma.authorizationAttempt.findUnique({ + where: { stateDigest: expired.stateDigest }, + }), + ).toBeNull() const results = await Promise.all([ - store.lock((records) => records.consumeAttempt(input.stateDigest)), - store.lock((records) => records.consumeAttempt(input.stateDigest)), + store.lock((records) => records.consumeAuthorizationAttempt(live.stateDigest)), + store.lock((records) => records.consumeAuthorizationAttempt(live.stateDigest)), ]) - expect(results.filter(Boolean)).toHaveLength(1) - expect(results.find(Boolean)).toMatchObject(input) - await store.lock((records) => records.createAttempt({ ...input, stateDigest: "pending-state" })) - await store.lock((records) => records.advanceGeneration()) - expect(await store.lock((records) => records.consumeAttempt("pending-state"))).toBeNull() + expect(results.filter(Boolean)).toHaveLength(1) + expect(results.find(Boolean)).toMatchObject(live) }) - it("invalidates fresh proof without deleting sessions and removes originating attempts on sign-out", async () => { + it("removes pending attempts with their originating session", async () => { const { store } = await database() - const session = await store.lock(async (records) => { - const user = await records.saveUser({ - providerId: "10001", - login: "synthetic-user", - name: null, - avatarUrl: null, - }) - const session = await records.createSession({ - userId: user.id, - tokenDigest: "synthetic-token-digest", - signedInAt: new Date(), - freshAuthorizedAt: new Date(), - credentialRevision: "synthetic-revision", - }) - await records.createAttempt({ - stateDigest: "synthetic-state-digest", - browserDigest: "synthetic-browser-digest", - verifier: "synthetic-verifier", - generation: 0, - credentialRevision: "synthetic-revision", - purpose: "verify", - sessionId: session.id, - expiresAt: new Date(Date.now() + 15 * 60_000), - }) - return session - }) + const { authenticationProviderRevision, session } = await createSession(store, "sign-out") + const attempt = { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: session.id, + stateDigest: "originating-session-state", + browserDigest: "originating-session-browser", + pkceVerifier: "originating-session-verifier", + nonce: null, + purpose: "verify" as const, + expiresAt: new Date(Date.now() + 15 * 60_000), + } + await store.lock((records) => records.createAuthorizationAttempt(attempt)) await store.lock((records) => records.invalidateFreshness()) expect(await store.read.sessionById(session.id)).toMatchObject({ freshAuthorizedAt: null }) await store.lock((records) => records.deleteSession(session.id)) - expect(await store.read.session("synthetic-token-digest")).toBeNull() + + expect(await store.read.session(session.tokenDigest)).toBeNull() expect( - await store.lock((records) => records.consumeAttempt("synthetic-state-digest")), + await store.lock((records) => records.consumeAuthorizationAttempt(attempt.stateDigest)), ).toBeNull() }) - it("hides audits older than ninety days and prunes them on writes while retaining the cutoff", async () => { + it("retains minimal structured audits for ninety days and prunes older records on writes", async () => { const { prisma, store } = await database() + const { authenticationProviderRevision, session, user } = await createSession(store, "audit") const now = Date.now() vi.spyOn(Date, "now").mockReturnValue(now) const cutoff = now - 90 * 24 * 60 * 60_000 @@ -178,14 +382,24 @@ describe("createIdentityStore()", () => { expect((await store.read.audits()).map(({ id }) => id)).toEqual([retained.id]) expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).not.toBeNull() await store.lock((records) => - records.audit({ event: "credentials.setup", actorId: null, sessionId: null }), + records.audit({ + event: "identity.verified", + actorUserId: user.id, + sessionId: session.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + }), ) expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).toBeNull() expect(await prisma.securityAudit.findUnique({ where: { id: retained.id } })).not.toBeNull() - expect((await store.read.audits()).map(({ event }) => event)).toEqual([ - "credentials.setup", - "session.sign_in", + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "identity.verified", + actorUserId: user.id, + sessionId: session.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + }), + expect.objectContaining({ id: retained.id, event: "session.sign_in" }), ]) expect(await prisma.securityAudit.count()).toBe(2) }) diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 39d3a962..378667d2 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -1,26 +1,66 @@ -import type { IdentityState } from "#server/repositories/identity-state/types" -import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" +import type { AuthenticationProviderInput } from "#server/repositories/authentication-providers/types" +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" +import type { + ExternalIdentityInput, + HubExternalIdentity, +} from "#server/repositories/external-identities/types" +import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" +import type { + InstallationMembershipInput, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" import type { AuditInput } from "#server/repositories/security-audits/types" import type { HubSession } from "#server/repositories/sessions/types" -import type { GitHubUserInput, HubUser } from "#server/repositories/users/types" +import type { HubUser, UserInput } from "#server/repositories/users/types" + +export interface AuthenticationProviderRevisionRecord { + id: string + authenticationProviderId: string + revision: number + issuer: string + configuration: unknown + authenticationProvider: { + id: string + key: string + type: string + enabled: boolean + } +} + +export interface SessionContext extends HubSession { + user: HubUser + externalIdentity: HubExternalIdentity + authenticationProviderRevision: AuthenticationProviderRevisionRecord +} export interface IdentityRecords { - state(): Promise - saveCredentials(encryptedCredentials: string): Promise - advanceGeneration(): Promise + githubIntegrationState(): Promise + saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise + advanceGitHubIntegrationGeneration(): Promise user(id: string): Promise - githubUser(providerId: string): Promise - saveUser(profile: GitHubUserInput): Promise - enroll(userId: string): Promise - session(tokenDigest: string): Promise<(HubSession & { user: HubUser }) | null> - sessionById(id: string): Promise<(HubSession & { user: HubUser }) | null> + createUser(input: UserInput): Promise + externalIdentity( + provider: string, + issuer: string, + subject: string, + ): Promise<(HubExternalIdentity & { user: HubUser }) | null> + saveExternalIdentity( + input: ExternalIdentityInput, + ): Promise + membership(userId: string): Promise + saveMembership(input: InstallationMembershipInput): Promise + createAuthenticationProviderRevision( + input: AuthenticationProviderInput, + ): Promise + session(tokenDigest: string): Promise + sessionById(id: string): Promise createSession(input: Omit): Promise - verifySession(id: string, at: Date, credentialRevision: string): Promise + verifySession(id: string, at: Date): Promise invalidateFreshness(): Promise deleteSession(id: string): Promise deleteSessions(): Promise - createAttempt(input: Omit): Promise - consumeAttempt(stateDigest: string): Promise + createAuthorizationAttempt(input: Omit): Promise + consumeAuthorizationAttempt(stateDigest: string): Promise audit(input: AuditInput): Promise audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> } diff --git a/hub/server/domains/identity/with-store-lock/index.ts b/hub/server/domains/identity/with-store-lock/index.ts index 7613f7fa..bcdabd78 100644 --- a/hub/server/domains/identity/with-store-lock/index.ts +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -1,5 +1,6 @@ import { createIdentityRecords } from "#server/domains/identity/create-records" -import { withIdentityLock } from "#server/repositories/identity-state/with-identity-lock" +import { ensureGitHubIntegrationState } from "#server/repositories/github-integration-state/ensure-state" +import { withIdentityStoreLock as withRepositoryLock } from "#server/repositories/identity-store-state/with-store-lock" import type { IdentityRecords } from "#server/domains/identity/create-store/types" import type { PrismaClient } from "../../../generated/prisma/client.ts" @@ -8,5 +9,8 @@ export function withIdentityStoreLock( client: PrismaClient, operation: (records: IdentityRecords) => Promise, ): Promise { - return withIdentityLock(client, (database) => operation(createIdentityRecords(database))) + return withRepositoryLock(client, async (database) => { + await ensureGitHubIntegrationState(database) + return operation(createIdentityRecords(database)) + }) } diff --git a/hub/server/repositories/authentication-providers/create-provider-revision/index.ts b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts new file mode 100644 index 00000000..606053b2 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts @@ -0,0 +1,23 @@ +import type { AuthenticationProviderInput } from "#server/repositories/authentication-providers/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createAuthenticationProviderRevision( + database: Prisma.TransactionClient, + input: AuthenticationProviderInput, +) { + const provider = await database.authenticationProvider.upsert({ + where: { key: input.key }, + create: { key: input.key, type: input.type, enabled: input.enabled }, + update: { type: input.type, enabled: input.enabled }, + }) + return database.authenticationProviderRevision.create({ + data: { + authenticationProviderId: provider.id, + revision: input.revision, + issuer: input.issuer, + configuration: input.configuration, + }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authentication-providers/types.ts b/hub/server/repositories/authentication-providers/types.ts new file mode 100644 index 00000000..8c320138 --- /dev/null +++ b/hub/server/repositories/authentication-providers/types.ts @@ -0,0 +1,10 @@ +import type { Prisma } from "../../generated/prisma/client.ts" + +export interface AuthenticationProviderInput { + key: string + type: "github" | "oidc" + enabled: boolean + revision: number + issuer: string + configuration: Prisma.InputJsonValue +} diff --git a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts new file mode 100644 index 00000000..e6acb84c --- /dev/null +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -0,0 +1,20 @@ +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function consumeAuthorizationAttempt( + database: Prisma.TransactionClient, + stateDigest: string, +): Promise { + const attempt = await database.authorizationAttempt.findUnique({ where: { stateDigest } }) + if (!attempt) return null + await database.authorizationAttempt.delete({ where: { id: attempt.id } }) + if ( + attempt.purpose !== "sign-in" && + attempt.purpose !== "verify" && + attempt.purpose !== "link-identity" + ) { + throw new Error("Stored authorization purpose is invalid.") + } + return { ...attempt, purpose: attempt.purpose } +} diff --git a/hub/server/repositories/authorization-attempts/create-attempt/index.ts b/hub/server/repositories/authorization-attempts/create-attempt/index.ts new file mode 100644 index 00000000..9660c8cf --- /dev/null +++ b/hub/server/repositories/authorization-attempts/create-attempt/index.ts @@ -0,0 +1,11 @@ +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createAuthorizationAttempt( + database: Prisma.TransactionClient, + data: Omit, +) { + await database.authorizationAttempt.deleteMany({ where: { expiresAt: { lte: new Date() } } }) + await database.authorizationAttempt.create({ data }) +} diff --git a/hub/server/repositories/authorization-attempts/types.ts b/hub/server/repositories/authorization-attempts/types.ts new file mode 100644 index 00000000..3687dc29 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/types.ts @@ -0,0 +1,11 @@ +export interface AuthorizationAttempt { + id: string + authenticationProviderRevisionId: string + originatingSessionId: string | null + stateDigest: string + browserDigest: string + pkceVerifier: string + nonce: string | null + purpose: "sign-in" | "verify" | "link-identity" + expiresAt: Date +} diff --git a/hub/server/repositories/external-identities/get-external-identity/index.ts b/hub/server/repositories/external-identities/get-external-identity/index.ts new file mode 100644 index 00000000..126c6da0 --- /dev/null +++ b/hub/server/repositories/external-identities/get-external-identity/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getExternalIdentity( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + subject: string, +) { + return database.externalIdentity.findUnique({ + where: { provider_issuer_subject: { provider, issuer, subject } }, + include: { user: true }, + }) +} diff --git a/hub/server/repositories/external-identities/save-external-identity/index.ts b/hub/server/repositories/external-identities/save-external-identity/index.ts new file mode 100644 index 00000000..9a386b2e --- /dev/null +++ b/hub/server/repositories/external-identities/save-external-identity/index.ts @@ -0,0 +1,20 @@ +import type { ExternalIdentityInput } from "#server/repositories/external-identities/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function saveExternalIdentity( + database: Prisma.TransactionClient, + identity: ExternalIdentityInput, +) { + const { provider, issuer, subject } = identity + return database.externalIdentity.upsert({ + where: { provider_issuer_subject: { provider, issuer, subject } }, + create: identity, + update: { + username: identity.username, + displayName: identity.displayName, + avatarUrl: identity.avatarUrl, + }, + include: { user: true }, + }) +} diff --git a/hub/server/repositories/external-identities/types.ts b/hub/server/repositories/external-identities/types.ts new file mode 100644 index 00000000..f35f00f4 --- /dev/null +++ b/hub/server/repositories/external-identities/types.ts @@ -0,0 +1,13 @@ +export interface ExternalIdentityInput { + userId: string + provider: string + issuer: string + subject: string + username: string | null + displayName: string | null + avatarUrl: string | null +} + +export interface HubExternalIdentity extends ExternalIdentityInput { + id: string +} diff --git a/hub/server/repositories/github-integration-state/advance-generation/index.ts b/hub/server/repositories/github-integration-state/advance-generation/index.ts new file mode 100644 index 00000000..92420a95 --- /dev/null +++ b/hub/server/repositories/github-integration-state/advance-generation/index.ts @@ -0,0 +1,11 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function advanceGitHubIntegrationGeneration(database: Prisma.TransactionClient) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId, generation: 1 }, + update: { generation: { increment: 1 } }, + }) +} diff --git a/hub/server/repositories/github-integration-state/constants.ts b/hub/server/repositories/github-integration-state/constants.ts new file mode 100644 index 00000000..b86d5af4 --- /dev/null +++ b/hub/server/repositories/github-integration-state/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process must lock the same row before changing GitHub integration credentials. +export const staticGitHubIntegrationRecordId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/github-integration-state/ensure-state/index.ts b/hub/server/repositories/github-integration-state/ensure-state/index.ts new file mode 100644 index 00000000..c3a1116f --- /dev/null +++ b/hub/server/repositories/github-integration-state/ensure-state/index.ts @@ -0,0 +1,11 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function ensureGitHubIntegrationState(database: Prisma.TransactionClient) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId }, + update: {}, + }) +} diff --git a/hub/server/repositories/github-integration-state/get-state/index.ts b/hub/server/repositories/github-integration-state/get-state/index.ts new file mode 100644 index 00000000..1107a6cf --- /dev/null +++ b/hub/server/repositories/github-integration-state/get-state/index.ts @@ -0,0 +1,9 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getGitHubIntegrationState(database: Prisma.TransactionClient) { + return database.gitHubIntegrationState.findUniqueOrThrow({ + where: { id: staticGitHubIntegrationRecordId }, + }) +} diff --git a/hub/server/repositories/github-integration-state/save-credentials/index.ts b/hub/server/repositories/github-integration-state/save-credentials/index.ts new file mode 100644 index 00000000..e053f527 --- /dev/null +++ b/hub/server/repositories/github-integration-state/save-credentials/index.ts @@ -0,0 +1,14 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveGitHubIntegrationCredentials( + database: Prisma.TransactionClient, + encryptedCredentials: string, +) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId, encryptedCredentials }, + update: { encryptedCredentials }, + }) +} diff --git a/hub/server/repositories/identity-state/types.ts b/hub/server/repositories/github-integration-state/types.ts similarity index 59% rename from hub/server/repositories/identity-state/types.ts rename to hub/server/repositories/github-integration-state/types.ts index 19042699..b26f045a 100644 --- a/hub/server/repositories/identity-state/types.ts +++ b/hub/server/repositories/github-integration-state/types.ts @@ -1,4 +1,4 @@ -export interface IdentityState { +export interface GitHubIntegrationState { generation: number encryptedCredentials: string | null } diff --git a/hub/server/repositories/identity-state/advance-generation/index.ts b/hub/server/repositories/identity-state/advance-generation/index.ts deleted file mode 100644 index 899e0c96..00000000 --- a/hub/server/repositories/identity-state/advance-generation/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export async function advanceIdentityGeneration(database: Prisma.TransactionClient) { - await database.identityState.update({ - where: { id: staticSettingsRecordId }, - data: { generation: { increment: 1 } }, - }) - await database.oAuthAttempt.deleteMany() -} diff --git a/hub/server/repositories/identity-state/constants.ts b/hub/server/repositories/identity-state/constants.ts deleted file mode 100644 index 19030771..00000000 --- a/hub/server/repositories/identity-state/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Every Hub process must lock the same settings row before changing identity state. -export const staticSettingsRecordId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/identity-state/get-state/index.ts b/hub/server/repositories/identity-state/get-state/index.ts deleted file mode 100644 index 401138ae..00000000 --- a/hub/server/repositories/identity-state/get-state/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export function getIdentityState(database: Prisma.TransactionClient) { - return database.identityState.findUniqueOrThrow({ where: { id: staticSettingsRecordId } }) -} diff --git a/hub/server/repositories/identity-state/save-credentials/index.ts b/hub/server/repositories/identity-state/save-credentials/index.ts deleted file mode 100644 index 264b0ebb..00000000 --- a/hub/server/repositories/identity-state/save-credentials/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export async function saveIdentityCredentials( - database: Prisma.TransactionClient, - encryptedCredentials: string, -) { - await database.identityState.update({ - where: { id: staticSettingsRecordId }, - data: { encryptedCredentials }, - }) -} diff --git a/hub/server/repositories/identity-state/with-identity-lock/index.ts b/hub/server/repositories/identity-state/with-identity-lock/index.ts deleted file mode 100644 index 4f964f4b..00000000 --- a/hub/server/repositories/identity-state/with-identity-lock/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { staticSettingsRecordId } from "#server/repositories/identity-state/constants" - -import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" - -export function withIdentityLock( - client: PrismaClient, - operation: (database: Prisma.TransactionClient) => Promise, -): Promise { - return client.$transaction(async (transaction) => { - await transaction.identityState.upsert({ - where: { id: staticSettingsRecordId }, - create: { id: staticSettingsRecordId }, - update: {}, - }) - await transaction.$queryRaw`SELECT id FROM identity_states WHERE id = ${staticSettingsRecordId}::uuid FOR UPDATE` - return operation(transaction) - }) -} diff --git a/hub/server/repositories/identity-store-state/constants.ts b/hub/server/repositories/identity-store-state/constants.ts new file mode 100644 index 00000000..3d42c96e --- /dev/null +++ b/hub/server/repositories/identity-store-state/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process must lock the same row before changing identity persistence. +export const staticIdentityStoreRecordId = "00000000-0000-4000-8000-000000000002" diff --git a/hub/server/repositories/identity-store-state/with-store-lock/index.ts b/hub/server/repositories/identity-store-state/with-store-lock/index.ts new file mode 100644 index 00000000..f8b8b247 --- /dev/null +++ b/hub/server/repositories/identity-store-state/with-store-lock/index.ts @@ -0,0 +1,18 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityStoreLock( + client: PrismaClient, + operation: (database: Prisma.TransactionClient) => Promise, +): Promise { + return client.$transaction(async (transaction) => { + await transaction.identityStoreState.upsert({ + where: { id: staticIdentityStoreRecordId }, + create: { id: staticIdentityStoreRecordId }, + update: {}, + }) + await transaction.$queryRaw`SELECT id FROM identity_store_states WHERE id = ${staticIdentityStoreRecordId}::uuid FOR UPDATE` + return operation(transaction) + }) +} diff --git a/hub/server/repositories/installation-memberships/get-membership/index.ts b/hub/server/repositories/installation-memberships/get-membership/index.ts new file mode 100644 index 00000000..167de2c0 --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-membership/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getInstallationMembership(database: Prisma.TransactionClient, userId: string) { + return database.installationMembership.findUnique({ where: { userId } }) +} diff --git a/hub/server/repositories/installation-memberships/save-membership/index.ts b/hub/server/repositories/installation-memberships/save-membership/index.ts new file mode 100644 index 00000000..f38357c9 --- /dev/null +++ b/hub/server/repositories/installation-memberships/save-membership/index.ts @@ -0,0 +1,15 @@ +import type { InstallationMembershipInput } from "#server/repositories/installation-memberships/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function saveInstallationMembership( + database: Prisma.TransactionClient, + input: InstallationMembershipInput, +) { + const { userId, ...membership } = input + return database.installationMembership.upsert({ + where: { userId }, + create: input, + update: membership, + }) +} diff --git a/hub/server/repositories/installation-memberships/types.ts b/hub/server/repositories/installation-memberships/types.ts new file mode 100644 index 00000000..41d75837 --- /dev/null +++ b/hub/server/repositories/installation-memberships/types.ts @@ -0,0 +1,14 @@ +export interface InstallationMembershipInput { + userId: string + status: "active" | "suspended" + role: "member" | "administrator" + source: "bootstrap" | "manual" | "synchronized" +} + +export interface InstallationMembershipRecord { + id: string + userId: string + status: string + role: string + source: string +} diff --git a/hub/server/repositories/oauth-attempts/consume-attempt/index.ts b/hub/server/repositories/oauth-attempts/consume-attempt/index.ts deleted file mode 100644 index fee04b3e..00000000 --- a/hub/server/repositories/oauth-attempts/consume-attempt/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export async function consumeOAuthAttempt( - database: Prisma.TransactionClient, - stateDigest: string, -): Promise { - const attempt = await database.oAuthAttempt.findUnique({ where: { stateDigest } }) - if (!attempt) return null - await database.oAuthAttempt.delete({ where: { id: attempt.id } }) - if ( - attempt.purpose !== "sign-in" && - attempt.purpose !== "verify" && - attempt.purpose !== "enroll" - ) { - throw new Error("Stored OAuth purpose is invalid.") - } - return { ...attempt, purpose: attempt.purpose } -} diff --git a/hub/server/repositories/oauth-attempts/create-attempt/index.ts b/hub/server/repositories/oauth-attempts/create-attempt/index.ts deleted file mode 100644 index fad43ef1..00000000 --- a/hub/server/repositories/oauth-attempts/create-attempt/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { OAuthAttempt } from "#server/repositories/oauth-attempts/types" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export async function createOAuthAttempt( - database: Prisma.TransactionClient, - data: Omit, -) { - await database.oAuthAttempt.deleteMany({ where: { expiresAt: { lte: new Date() } } }) - await database.oAuthAttempt.create({ data }) -} diff --git a/hub/server/repositories/oauth-attempts/types.ts b/hub/server/repositories/oauth-attempts/types.ts deleted file mode 100644 index 91d56160..00000000 --- a/hub/server/repositories/oauth-attempts/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface OAuthAttempt { - id: string - stateDigest: string - browserDigest: string - verifier: string - generation: number - credentialRevision: string - purpose: "sign-in" | "verify" | "enroll" - sessionId: string | null - expiresAt: Date -} diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index ce9f5c5f..ab72e801 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -1,5 +1,6 @@ export interface AuditInput { event: string - actorId: string | null + actorUserId: string | null sessionId: string | null + authenticationProviderRevisionId: string | null } diff --git a/hub/server/repositories/sessions/create-session/index.ts b/hub/server/repositories/sessions/create-session/index.ts index a5afcd10..4dcd48c0 100644 --- a/hub/server/repositories/sessions/create-session/index.ts +++ b/hub/server/repositories/sessions/create-session/index.ts @@ -2,6 +2,16 @@ import type { HubSession } from "#server/repositories/sessions/types" import type { Prisma } from "../../../generated/prisma/client.ts" -export function createSession(database: Prisma.TransactionClient, data: Omit) { +export async function createSession( + database: Prisma.TransactionClient, + data: Omit, +) { + const identity = await database.externalIdentity.findUnique({ + where: { id: data.externalIdentityId }, + select: { userId: true }, + }) + if (identity?.userId !== data.userId) { + throw new Error("Session external identity must belong to the Hub user.") + } return database.session.create({ data }) } diff --git a/hub/server/repositories/sessions/delete-session/index.ts b/hub/server/repositories/sessions/delete-session/index.ts index 5c1b0857..439d061e 100644 --- a/hub/server/repositories/sessions/delete-session/index.ts +++ b/hub/server/repositories/sessions/delete-session/index.ts @@ -2,5 +2,4 @@ import type { Prisma } from "../../../generated/prisma/client.ts" export async function deleteSession(database: Prisma.TransactionClient, id: string) { await database.session.deleteMany({ where: { id } }) - await database.oAuthAttempt.deleteMany({ where: { sessionId: id } }) } diff --git a/hub/server/repositories/sessions/get-session-by-id/index.ts b/hub/server/repositories/sessions/get-session-by-id/index.ts index c99dd229..4d7e4df6 100644 --- a/hub/server/repositories/sessions/get-session-by-id/index.ts +++ b/hub/server/repositories/sessions/get-session-by-id/index.ts @@ -1,5 +1,12 @@ import type { Prisma } from "../../../generated/prisma/client.ts" export function getSessionById(database: Prisma.TransactionClient, id: string) { - return database.session.findUnique({ where: { id }, include: { user: true } }) + return database.session.findUnique({ + where: { id }, + include: { + user: true, + externalIdentity: true, + authenticationProviderRevision: { include: { authenticationProvider: true } }, + }, + }) } diff --git a/hub/server/repositories/sessions/get-session/index.ts b/hub/server/repositories/sessions/get-session/index.ts index 731c2e24..746b6750 100644 --- a/hub/server/repositories/sessions/get-session/index.ts +++ b/hub/server/repositories/sessions/get-session/index.ts @@ -1,5 +1,12 @@ import type { Prisma } from "../../../generated/prisma/client.ts" export function getSession(database: Prisma.TransactionClient, tokenDigest: string) { - return database.session.findUnique({ where: { tokenDigest }, include: { user: true } }) + return database.session.findUnique({ + where: { tokenDigest }, + include: { + user: true, + externalIdentity: true, + authenticationProviderRevision: { include: { authenticationProvider: true } }, + }, + }) } diff --git a/hub/server/repositories/sessions/types.ts b/hub/server/repositories/sessions/types.ts index eb971187..f49defe5 100644 --- a/hub/server/repositories/sessions/types.ts +++ b/hub/server/repositories/sessions/types.ts @@ -1,8 +1,9 @@ export interface HubSession { id: string userId: string + externalIdentityId: string + authenticationProviderRevisionId: string tokenDigest: string signedInAt: Date freshAuthorizedAt: Date | null - credentialRevision: string } diff --git a/hub/server/repositories/sessions/verify-session/index.ts b/hub/server/repositories/sessions/verify-session/index.ts index a2b744f9..2b278df8 100644 --- a/hub/server/repositories/sessions/verify-session/index.ts +++ b/hub/server/repositories/sessions/verify-session/index.ts @@ -4,10 +4,9 @@ export async function verifySession( database: Prisma.TransactionClient, id: string, freshAuthorizedAt: Date, - credentialRevision: string, ) { await database.session.update({ where: { id }, - data: { freshAuthorizedAt, credentialRevision }, + data: { freshAuthorizedAt }, }) } 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..3282981a --- /dev/null +++ b/hub/server/repositories/users/create-user/index.ts @@ -0,0 +1,7 @@ +import type { UserInput } from "#server/repositories/users/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function createUser(database: Prisma.TransactionClient, input: UserInput) { + return database.user.create({ data: input }) +} diff --git a/hub/server/repositories/users/enroll-administrator/index.ts b/hub/server/repositories/users/enroll-administrator/index.ts deleted file mode 100644 index 8f7c84ed..00000000 --- a/hub/server/repositories/users/enroll-administrator/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Prisma } from "../../../generated/prisma/client.ts" - -export function enrollAdministrator(database: Prisma.TransactionClient, id: string) { - return database.user.update({ where: { id }, data: { administrator: true } }) -} diff --git a/hub/server/repositories/users/get-github-user/index.ts b/hub/server/repositories/users/get-github-user/index.ts deleted file mode 100644 index 033275b6..00000000 --- a/hub/server/repositories/users/get-github-user/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Prisma } from "../../../generated/prisma/client.ts" - -export function getGitHubUser(database: Prisma.TransactionClient, providerId: string) { - return database.user.findUnique({ - where: { provider_providerId: { provider: "github", providerId } }, - }) -} diff --git a/hub/server/repositories/users/save-github-user/index.ts b/hub/server/repositories/users/save-github-user/index.ts deleted file mode 100644 index aee7135a..00000000 --- a/hub/server/repositories/users/save-github-user/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { GitHubUserInput } from "#server/repositories/users/types" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export function saveGitHubUser(database: Prisma.TransactionClient, profile: GitHubUserInput) { - return database.user.upsert({ - where: { provider_providerId: { provider: "github", providerId: profile.providerId } }, - create: { ...profile, provider: "github" }, - update: profile, - }) -} diff --git a/hub/server/repositories/users/types.ts b/hub/server/repositories/users/types.ts index 6f82d79e..50eb5c67 100644 --- a/hub/server/repositories/users/types.ts +++ b/hub/server/repositories/users/types.ts @@ -1,11 +1,7 @@ export interface HubUser { id: string - provider: string - providerId: string - login: string - name: string | null + displayName: string | null avatarUrl: string | null - administrator: boolean } -export type GitHubUserInput = Pick +export type UserInput = Omit From ebcd4c63a42a335002a21976b521e1e241d1eaa5 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 12:31:13 -0700 Subject: [PATCH 3/5] fix(hub): harden authentication state handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/logging.md | 8 +- .../identity/create-store/index.db.test.ts | 158 +++++++++++++++++- .../pino/create-request-logger/index.test.ts | 88 +++++++++- .../pino/omit-request-log-payload/index.ts | 7 + .../pino/snapshot-request/index.ts | 7 +- .../create-provider-revision/index.ts | 12 +- .../consume-attempt/index.ts | 34 +++- .../sessions/create-session/index.ts | 23 ++- .../logging/sanitize-log-value/constants.ts | 2 + .../logging/sanitize-log-value/index.test.ts | 18 ++ .../utils/logging/sanitize-log-value/index.ts | 13 +- .../utils/logging/visit-log-value/index.ts | 3 +- .../utils/logging/visit-log-value/types.ts | 1 + 13 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 hub/server/integrations/pino/omit-request-log-payload/index.ts diff --git a/hub/docs/logging.md b/hub/docs/logging.md index a32eb149..47fca2c8 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,10 +30,12 @@ Each request produces one final record with `reqId`, `method`, `route`, `statusC Automatic records exclude raw URLs, headers, cookies, uploads, response bodies, and error details. JSON parsing uses Express's default 100 KiB limit; rejected bodies are not logged. The Express error handler returns generic JSON errors, or closes the connection if headers were sent, without exposing raw errors. -For private free text or other sensitive payloads, exclude all payload fields before sending a response: +Authentication routers should install `omitRequestLogPayload` before their handlers so callback payloads are excluded by default: ```ts -response.locals.omitLogPayload = true +import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" + +router.use(omitRequestLogPayload) ``` This omits `params`, `query`, and `body` while keeping request metadata. @@ -42,6 +44,6 @@ This omits `params`, `query`, and `body` while keeping request metadata. Application fields, `request.log` fields, and automatic payloads share the same sanitizer. It copies data without changing the request or invoking getters or custom serialization methods. -Keys containing password, passwd, passphrase, secret, token, authorization, cookie, API key, private key, or session ID become `[REDACTED]`. Matching ignores case and punctuation and includes nested objects and arrays. Exclude payloads whose sensitive fields do not match these rules. +Keys containing password, passwd, passphrase, secret, token, authorization, cookie, API key, private key, or session ID become `[REDACTED]`. Automatic request payloads also redact OAuth and OIDC `code`, `state`, `nonce`, and `code_verifier` fields. Matching ignores case and punctuation and includes nested objects and arrays. Exclude payloads whose sensitive fields do not match these rules. Structured fields have [size, depth, and entry limits](../server/utils/logging/sanitize-log-value/constants.ts). Markers replace truncated data, cycles, getters, and unsupported values. These limits do not apply to messages or custom bindings and do not replace endpoint validation or privacy decisions. diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts index b5eb4c51..5bab0caa 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createIdentityStore } from "#server/domains/identity/create-store" +import { consumeAuthorizationAttempt } from "#server/repositories/authorization-attempts/consume-attempt" type IdentityStore = ReturnType @@ -285,7 +286,153 @@ describe("createIdentityStore()", () => { }) }) - it("prunes expired provider-aware attempts and consumes a live attempt once", async () => { + it("rejects a session when the external identity provider type differs", async () => { + const { store } = await database() + const user = await store.lock((records) => + records.createUser({ displayName: "Provider mismatch", avatarUrl: null }), + ) + const externalIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://login.example", + subject: "provider-mismatch", + username: "provider-mismatch", + displayName: "Provider mismatch", + avatarUrl: null, + }), + ) + const authenticationProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "provider-mismatch", + type: "oidc", + enabled: true, + revision: 1, + issuer: "https://login.example", + configuration: { clientId: "provider-mismatch" }, + }), + ) + + await expect( + store.lock((records) => + records.createSession({ + userId: user.id, + externalIdentityId: externalIdentity.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + tokenDigest: "provider-mismatch-token", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ), + ).rejects.toThrow("Session external identity provider must match") + expect(await store.read.session("provider-mismatch-token")).toBeNull() + }) + + it("rejects a session when the external identity issuer differs", async () => { + const { store } = await database() + const user = await store.lock((records) => + records.createUser({ displayName: "Issuer mismatch", avatarUrl: null }), + ) + const externalIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: "https://identity.example", + subject: "issuer-mismatch", + username: "issuer-mismatch", + displayName: "Issuer mismatch", + avatarUrl: null, + }), + ) + const authenticationProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "issuer-mismatch", + type: "oidc", + enabled: true, + revision: 1, + issuer: "https://provider.example", + configuration: { clientId: "issuer-mismatch" }, + }), + ) + + await expect( + store.lock((records) => + records.createSession({ + userId: user.id, + externalIdentityId: externalIdentity.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + tokenDigest: "issuer-mismatch-token", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ), + ).rejects.toThrow("Session external identity issuer must match") + expect(await store.read.session("issuer-mismatch-token")).toBeNull() + }) + + it("keeps a provider type and historical session context after rejecting a type change", async () => { + const { store } = await database() + const { authenticationProviderRevision, session } = await createSession( + store, + "immutable-provider-type", + ) + + await expect( + store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "oidc-immutable-provider-type", + type: "github", + enabled: false, + revision: 2, + issuer: "https://github.com", + configuration: { clientId: "replacement-client" }, + }), + ), + ).rejects.toThrow("Authentication provider type cannot change") + + expect(await store.read.session(session.tokenDigest)).toMatchObject({ + authenticationProviderRevision: { + id: authenticationProviderRevision.id, + revision: 1, + authenticationProvider: { + key: "oidc-immutable-provider-type", + type: "oidc", + enabled: true, + }, + }, + }) + }) + + it("deletes an attempt expiring at the database transaction time without consuming it", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision } = await createIdentityGraph(store, "expiry-boundary") + const stateDigest = "expiry-boundary-state" + + const consumed = await prisma.$transaction(async (database) => { + const [clock] = await database.$queryRaw<{ now: Date }[]>` + SELECT CURRENT_TIMESTAMP AS now + ` + if (!clock) throw new Error("Expected the database transaction time") + await database.authorizationAttempt.create({ + data: { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: null, + stateDigest, + browserDigest: "expiry-boundary-browser", + pkceVerifier: "expiry-boundary-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: clock.now, + }, + }) + return consumeAuthorizationAttempt(database, stateDigest) + }) + + expect(consumed).toBeNull() + expect(await prisma.authorizationAttempt.findUnique({ where: { stateDigest } })).toBeNull() + }) + + it("prunes expired provider-aware attempts and atomically consumes a live attempt once", async () => { const { prisma, store } = await database() const { authenticationProviderRevision, session } = await createSession(store, "attempt") const otherProviderRevision = await store.lock((records) => @@ -327,12 +474,17 @@ describe("createIdentityStore()", () => { }), ).toBeNull() const results = await Promise.all([ - store.lock((records) => records.consumeAuthorizationAttempt(live.stateDigest)), - store.lock((records) => records.consumeAuthorizationAttempt(live.stateDigest)), + prisma.$transaction((database) => consumeAuthorizationAttempt(database, live.stateDigest)), + prisma.$transaction((database) => consumeAuthorizationAttempt(database, live.stateDigest)), ]) expect(results.filter(Boolean)).toHaveLength(1) expect(results.find(Boolean)).toMatchObject(live) + expect( + await prisma.authorizationAttempt.findUnique({ + where: { stateDigest: live.stateDigest }, + }), + ).toBeNull() }) it("removes pending attempts with their originating session", async () => { diff --git a/hub/server/integrations/pino/create-request-logger/index.test.ts b/hub/server/integrations/pino/create-request-logger/index.test.ts index 9c78c3a4..78f967e7 100644 --- a/hub/server/integrations/pino/create-request-logger/index.test.ts +++ b/hub/server/integrations/pino/create-request-logger/index.test.ts @@ -5,6 +5,7 @@ import express from "express" import { describe, expect, it, onTestFinished, vi } from "vitest" import { createLogger } from "../create-logger" +import { omitRequestLogPayload } from "../omit-request-log-payload" import { createRequestLogger } from "." @@ -111,7 +112,7 @@ describe("createRequestLogger()", () => { expect(records[0]).toMatchObject({ level: 50, statusCode: 500, outcome: "completed" }) }) - it("lets an endpoint omit all payload fields without suppressing completion metadata", async () => { + it("redacts normalized OAuth callback fields without changing the request", async () => { const records: Record[] = [] const logger = createLogger({ destination: { write: (line) => records.push(parseRecord(line)) }, @@ -119,23 +120,94 @@ describe("createRequestLogger()", () => { const app = express() app.use(createRequestLogger(logger)) app.use(express.json()) - app.post("/private/:id", (_req, res) => { - res.locals.omitLogPayload = true - res.json({ private: "response-secret" }) + app.post("/callback", (req, res) => { + const body: unknown = req.body + res.json({ query: req.query, body }) }) const baseUrl = await listen(app) + const query = new URLSearchParams({ + code: "query-code", + STATE: "query-state", + "no-nce": "query-nonce", + code_verifier: "query-verifier", + safe: "query-safe", + }) + const body = { + Code: "body-code", + state: "body-state", + nonce: "body-nonce", + "code.verifier": "body-verifier", + safe: "body-safe", + } - const response = await fetch(`${baseUrl}/private/param-secret?search=query-secret`, { + const response = await fetch(`${baseUrl}/callback?${query}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ text: "body-secret" }), + body: JSON.stringify(body), + }) + + expect(await response.json()).toEqual({ + query: { + code: "query-code", + STATE: "query-state", + "no-nce": "query-nonce", + code_verifier: "query-verifier", + safe: "query-safe", + }, + body, + }) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).toMatchObject({ + query: { + code: "[REDACTED]", + STATE: "[REDACTED]", + "no-nce": "[REDACTED]", + code_verifier: "[REDACTED]", + safe: "query-safe", + }, + body: { + Code: "[REDACTED]", + state: "[REDACTED]", + nonce: "[REDACTED]", + "code.verifier": "[REDACTED]", + safe: "body-safe", + }, + }) + expect(JSON.stringify(records)).not.toMatch( + /query-code|query-state|query-nonce|query-verifier|body-code|body-state|body-nonce|body-verifier/, + ) + }) + + it("lets an authentication router omit payload fields without suppressing completion metadata", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { write: (line) => records.push(parseRecord(line)) }, }) + const app = express() + app.use(createRequestLogger(logger)) + app.use(express.json()) + const authentication = express.Router() + authentication.use(omitRequestLogPayload) + authentication.post("/:provider/callback", (_req, res) => { + res.json({ private: "response-secret" }) + }) + app.use("/authentication", authentication) + const baseUrl = await listen(app) + + const response = await fetch( + `${baseUrl}/authentication/provider-secret/callback?search=query-secret`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "body-secret" }), + }, + ) await response.arrayBuffer() await vi.waitFor(() => expect(records).toHaveLength(1)) expect(records[0]).toMatchObject({ method: "POST", - route: "/private/:id", + route: "/:provider/callback", statusCode: 200, outcome: "completed", }) @@ -143,7 +215,7 @@ describe("createRequestLogger()", () => { expect(records[0]).not.toHaveProperty(field) } expect(JSON.stringify(records)).not.toMatch( - /param-secret|query-secret|body-secret|response-secret/, + /provider-secret|query-secret|body-secret|response-secret/, ) }) diff --git a/hub/server/integrations/pino/omit-request-log-payload/index.ts b/hub/server/integrations/pino/omit-request-log-payload/index.ts new file mode 100644 index 00000000..b9393eaa --- /dev/null +++ b/hub/server/integrations/pino/omit-request-log-payload/index.ts @@ -0,0 +1,7 @@ +import type { NextFunction, Request, Response } from "express" + +/** Exclude authentication request payloads while retaining completion metadata. */ +export function omitRequestLogPayload(_request: Request, response: Response, next: NextFunction) { + response.locals.omitLogPayload = true + next() +} diff --git a/hub/server/integrations/pino/snapshot-request/index.ts b/hub/server/integrations/pino/snapshot-request/index.ts index 8a1b9d15..6f2daf2c 100644 --- a/hub/server/integrations/pino/snapshot-request/index.ts +++ b/hub/server/integrations/pino/snapshot-request/index.ts @@ -1,5 +1,6 @@ import type { Request } from "express" +import { sensitiveRequestLogKeyPattern } from "../../../utils/logging/sanitize-log-value/constants.ts" import { sanitizeLogValue } from "../../../utils/logging/sanitize-log-value/index.ts" import type { RequestSnapshot } from "./types.ts" @@ -11,10 +12,10 @@ export function snapshotRequest(request: Request): RequestSnapshot { route !== null && typeof route === "object" && "path" in route ? route.path : undefined return { route: typeof path === "string" ? path : null, - params: sanitizeLogValue(request.params), - query: sanitizeLogValue(request.query), + params: sanitizeLogValue(request.params, sensitiveRequestLogKeyPattern), + query: sanitizeLogValue(request.query, sensitiveRequestLogKeyPattern), ...(request.is("application/json") && request.body !== undefined - ? { body: sanitizeLogValue(request.body) } + ? { body: sanitizeLogValue(request.body, sensitiveRequestLogKeyPattern) } : {}), } } diff --git a/hub/server/repositories/authentication-providers/create-provider-revision/index.ts b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts index 606053b2..23cc1a54 100644 --- a/hub/server/repositories/authentication-providers/create-provider-revision/index.ts +++ b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts @@ -6,11 +6,21 @@ export async function createAuthenticationProviderRevision( database: Prisma.TransactionClient, input: AuthenticationProviderInput, ) { + const existing = await database.authenticationProvider.findUnique({ + where: { key: input.key }, + select: { type: true }, + }) + if (existing && existing.type !== input.type) { + throw new Error("Authentication provider type cannot change.") + } const provider = await database.authenticationProvider.upsert({ where: { key: input.key }, create: { key: input.key, type: input.type, enabled: input.enabled }, - update: { type: input.type, enabled: input.enabled }, + update: { enabled: input.enabled }, }) + if (provider.type !== input.type) { + throw new Error("Authentication provider type cannot change.") + } return database.authenticationProviderRevision.create({ data: { authenticationProviderId: provider.id, diff --git a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts index e6acb84c..45438aa2 100644 --- a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -2,13 +2,31 @@ import type { AuthorizationAttempt } from "#server/repositories/authorization-at import type { Prisma } from "../../../generated/prisma/client.ts" +interface ConsumedAuthorizationAttempt extends AuthorizationAttempt { + valid: boolean +} + export async function consumeAuthorizationAttempt( database: Prisma.TransactionClient, stateDigest: string, ): Promise { - const attempt = await database.authorizationAttempt.findUnique({ where: { stateDigest } }) + const [attempt] = await database.$queryRaw` + DELETE FROM authorization_attempts + WHERE state_digest = ${stateDigest} + RETURNING + id, + authentication_provider_revision_id AS "authenticationProviderRevisionId", + originating_session_id AS "originatingSessionId", + state_digest AS "stateDigest", + browser_digest AS "browserDigest", + pkce_verifier AS "pkceVerifier", + nonce, + purpose, + expires_at AS "expiresAt", + expires_at > CURRENT_TIMESTAMP AS valid + ` if (!attempt) return null - await database.authorizationAttempt.delete({ where: { id: attempt.id } }) + if (!attempt.valid) return null if ( attempt.purpose !== "sign-in" && attempt.purpose !== "verify" && @@ -16,5 +34,15 @@ export async function consumeAuthorizationAttempt( ) { throw new Error("Stored authorization purpose is invalid.") } - return { ...attempt, purpose: attempt.purpose } + return { + id: attempt.id, + authenticationProviderRevisionId: attempt.authenticationProviderRevisionId, + originatingSessionId: attempt.originatingSessionId, + stateDigest: attempt.stateDigest, + browserDigest: attempt.browserDigest, + pkceVerifier: attempt.pkceVerifier, + nonce: attempt.nonce, + purpose: attempt.purpose, + expiresAt: attempt.expiresAt, + } } diff --git a/hub/server/repositories/sessions/create-session/index.ts b/hub/server/repositories/sessions/create-session/index.ts index 4dcd48c0..8a8e8a5d 100644 --- a/hub/server/repositories/sessions/create-session/index.ts +++ b/hub/server/repositories/sessions/create-session/index.ts @@ -6,12 +6,27 @@ export async function createSession( database: Prisma.TransactionClient, data: Omit, ) { - const identity = await database.externalIdentity.findUnique({ - where: { id: data.externalIdentityId }, - select: { userId: true }, - }) + const [identity, providerRevision] = await Promise.all([ + database.externalIdentity.findUnique({ + where: { id: data.externalIdentityId }, + select: { userId: true, provider: true, issuer: true }, + }), + database.authenticationProviderRevision.findUnique({ + where: { id: data.authenticationProviderRevisionId }, + select: { issuer: true, authenticationProvider: { select: { type: true } } }, + }), + ]) if (identity?.userId !== data.userId) { throw new Error("Session external identity must belong to the Hub user.") } + if (!providerRevision) { + throw new Error("Session authentication provider revision does not exist.") + } + if (identity.provider !== providerRevision.authenticationProvider.type) { + throw new Error("Session external identity provider must match the authentication provider.") + } + if (identity.issuer !== providerRevision.issuer) { + throw new Error("Session external identity issuer must match the provider revision.") + } return database.session.create({ data }) } diff --git a/hub/server/utils/logging/sanitize-log-value/constants.ts b/hub/server/utils/logging/sanitize-log-value/constants.ts index d15e3596..ce7fab5a 100644 --- a/hub/server/utils/logging/sanitize-log-value/constants.ts +++ b/hub/server/utils/logging/sanitize-log-value/constants.ts @@ -6,3 +6,5 @@ export const maxLogKeyLength = 128 export const truncationMarker = "[TRUNCATED]" export const sensitiveLogKeyPattern = /(password|passwd|passphrase|secret|token|authorization|cookie|apikey|privatekey|sessionid)/ +export const sensitiveRequestLogKeyPattern = + /(?:password|passwd|passphrase|secret|token|authorization|cookie|apikey|privatekey|sessionid|^(?:code|state|nonce|codeverifier)$)/ diff --git a/hub/server/utils/logging/sanitize-log-value/index.test.ts b/hub/server/utils/logging/sanitize-log-value/index.test.ts index ecb60b54..1e623fbf 100644 --- a/hub/server/utils/logging/sanitize-log-value/index.test.ts +++ b/hub/server/utils/logging/sanitize-log-value/index.test.ts @@ -25,6 +25,24 @@ describe("sanitizeLogValue()", () => { expect(input).toEqual(original) }) + it("keeps callback-shaped fields in general application log data", () => { + const input = { + code: "application-code", + STATE: "application-state", + "no-nce": "application-nonce", + code_verifier: "application-verifier", + access_token: "application-token", + } + + expect(sanitizeLogValue(input)).toEqual({ + code: "application-code", + STATE: "application-state", + "no-nce": "application-nonce", + code_verifier: "application-verifier", + access_token: "[REDACTED]", + }) + }) + it("makes cycles and non-JSON primitives safe to serialize", () => { const input: Record = { title: "Keep this", diff --git a/hub/server/utils/logging/sanitize-log-value/index.ts b/hub/server/utils/logging/sanitize-log-value/index.ts index fd81e426..aa9960cc 100644 --- a/hub/server/utils/logging/sanitize-log-value/index.ts +++ b/hub/server/utils/logging/sanitize-log-value/index.ts @@ -2,11 +2,18 @@ import { Buffer } from "node:buffer" import { visitLogValue } from "../visit-log-value/index.ts" -import { maxLogBytes } from "./constants.ts" +import { maxLogBytes, sensitiveLogKeyPattern } from "./constants.ts" import type { LogValue } from "./types.ts" /** Copy structured data with secret-key redaction and bounded JSON output. */ -export function sanitizeLogValue(value: unknown): LogValue { - const result = visitLogValue(value, 0, { ancestors: new WeakSet(), entries: 0 }) +export function sanitizeLogValue( + value: unknown, + sensitiveKeyPattern: RegExp = sensitiveLogKeyPattern, +): LogValue { + const result = visitLogValue(value, 0, { + ancestors: new WeakSet(), + entries: 0, + sensitiveKeyPattern, + }) return Buffer.byteLength(JSON.stringify(result)) <= maxLogBytes ? result : "[OMITTED: MAX BYTES]" } diff --git a/hub/server/utils/logging/visit-log-value/index.ts b/hub/server/utils/logging/visit-log-value/index.ts index 247eb881..902cccff 100644 --- a/hub/server/utils/logging/visit-log-value/index.ts +++ b/hub/server/utils/logging/visit-log-value/index.ts @@ -3,7 +3,6 @@ import { maxLogEntries, maxLogKeyLength, maxLogStringLength, - sensitiveLogKeyPattern, truncationMarker, } from "../sanitize-log-value/constants.ts" import type { LogValue } from "../sanitize-log-value/types.ts" @@ -45,7 +44,7 @@ export function visitLogValue(input: unknown, depth: number, traversal: LogTrave const descriptor = Object.getOwnPropertyDescriptor(input, key) if (!descriptor) continue let result: LogValue - if (sensitiveLogKeyPattern.test(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) { + if (traversal.sensitiveKeyPattern.test(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) { result = "[REDACTED]" } else if ("value" in descriptor) { result = visitLogValue(descriptor.value, depth + 1, traversal) diff --git a/hub/server/utils/logging/visit-log-value/types.ts b/hub/server/utils/logging/visit-log-value/types.ts index 9446a8f0..bb3c8573 100644 --- a/hub/server/utils/logging/visit-log-value/types.ts +++ b/hub/server/utils/logging/visit-log-value/types.ts @@ -1,4 +1,5 @@ export interface LogTraversal { ancestors: WeakSet entries: number + sensitiveKeyPattern: RegExp } From 980be2d6ffb078a25e766edaaf5acef9297579e3 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 12:35:38 -0700 Subject: [PATCH 4/5] fix(hub): evaluate attempt expiry after lock Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../identity/create-store/index.db.test.ts | 78 +++++++++++++++++++ .../consume-attempt/index.ts | 2 +- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts index 5bab0caa..06d2f2d6 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -432,6 +432,84 @@ describe("createIdentityStore()", () => { expect(await prisma.authorizationAttempt.findUnique({ where: { stateDigest } })).toBeNull() }) + it("rejects an attempt that expires while its consumer waits for the store lock", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision } = await createIdentityGraph(store, "waiting-expiry") + const stateDigest = "waiting-expiry-state" + await store.lock(() => Promise.resolve()) + let announceAttempt!: (expiresAt: Date) => void + let releaseLock!: () => void + const attemptCreated = new Promise((resolve) => { + announceAttempt = resolve + }) + const lockGate = new Promise((resolve) => { + releaseLock = resolve + }) + const holder = store.lock(async (records) => { + const expiresAt = new Date(Date.now() + 1_500) + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: null, + stateDigest, + browserDigest: "waiting-expiry-browser", + pkceVerifier: "waiting-expiry-verifier", + nonce: null, + purpose: "sign-in", + expiresAt, + }) + announceAttempt(expiresAt) + await lockGate + }) + const expiresAt = await attemptCreated + let consumerEntered = false + const consumer = store.lock(async (records) => { + consumerEntered = true + return records.consumeAuthorizationAttempt(stateDigest) + }) + onTestFinished(async () => { + releaseLock() + await Promise.allSettled([holder, consumer]) + }) + + await vi.waitFor( + async () => { + const [waiting] = await prisma.$queryRaw< + { transactionStartedAt: Date; waitEventType: string | null }[] + >` + SELECT + xact_start AS "transactionStartedAt", + wait_event_type AS "waitEventType" + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND state = 'active' + AND query LIKE '%identity_store_states%' + AND wait_event_type = 'Lock' + ORDER BY xact_start DESC + LIMIT 1 + ` + expect(waiting?.waitEventType).toBe("Lock") + expect(waiting?.transactionStartedAt.getTime()).toBeLessThan(expiresAt.getTime()) + }, + { interval: 20, timeout: 5_000 }, + ) + await vi.waitFor( + async () => { + const [clock] = await prisma.$queryRaw<{ expired: boolean }[]>` + SELECT clock_timestamp() >= ${expiresAt} AS expired + ` + expect(clock?.expired).toBe(true) + }, + { interval: 20, timeout: 5_000 }, + ) + expect(consumerEntered).toBe(false) + releaseLock() + + await holder + expect(await consumer).toBeNull() + expect(await prisma.authorizationAttempt.findUnique({ where: { stateDigest } })).toBeNull() + }) + it("prunes expired provider-aware attempts and atomically consumes a live attempt once", async () => { const { prisma, store } = await database() const { authenticationProviderRevision, session } = await createSession(store, "attempt") diff --git a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts index 45438aa2..669ddec7 100644 --- a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -23,7 +23,7 @@ export async function consumeAuthorizationAttempt( nonce, purpose, expires_at AS "expiresAt", - expires_at > CURRENT_TIMESTAMP AS valid + expires_at > clock_timestamp() AS valid ` if (!attempt) return null if (!attempt.valid) return null From 738dc31167943e050f80ce4a5371466bfa5b765c Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 08:38:02 -0700 Subject: [PATCH 5/5] fix(hub): address identity foundation review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 2 +- hub/docs/architecture.md | 2 +- hub/docs/backend-architecture.md | 4 +- hub/docs/identity.md | 18 +++-- hub/docs/testing-and-operations.md | 4 +- .../authentication-provider-revision.prisma | 23 ++++++ hub/schema/authentication-provider.prisma | 24 ------ ... => github-integration-credentials.prisma} | 5 +- .../20261026195151_identity/migration.sql | 7 +- .../create-credential-provider/index.test.ts | 14 ++-- .../create-credential-provider/types.ts | 4 +- .../domains/identity/create-records/index.ts | 8 +- .../identity/create-store/index.db.test.ts | 75 ++++++++++++++----- .../domains/identity/create-store/index.ts | 15 +++- .../domains/identity/create-store/types.ts | 21 +++++- .../domains/identity/with-store-lock/index.ts | 4 +- .../decrypt-github-credentials/index.test.ts | 51 +++++++++++++ .../decrypt-github-credentials/index.ts | 4 +- .../constants.ts | 2 + .../ensure-credentials/index.ts | 11 +++ .../get-credentials/index.ts | 9 +++ .../save-credentials/index.ts | 14 ++++ .../github-integration-credentials/types.ts | 3 + .../advance-generation/index.ts | 11 --- .../github-integration-state/constants.ts | 2 - .../ensure-state/index.ts | 11 --- .../get-state/index.ts | 9 --- .../save-credentials/index.ts | 14 ---- .../github-integration-state/types.ts | 4 - 29 files changed, 236 insertions(+), 139 deletions(-) create mode 100644 hub/schema/authentication-provider-revision.prisma rename hub/schema/{github-integration-state.prisma => github-integration-credentials.prisma} (73%) create mode 100644 hub/server/integrations/github/decrypt-github-credentials/index.test.ts create mode 100644 hub/server/repositories/github-integration-credentials/constants.ts create mode 100644 hub/server/repositories/github-integration-credentials/ensure-credentials/index.ts create mode 100644 hub/server/repositories/github-integration-credentials/get-credentials/index.ts create mode 100644 hub/server/repositories/github-integration-credentials/save-credentials/index.ts create mode 100644 hub/server/repositories/github-integration-credentials/types.ts delete mode 100644 hub/server/repositories/github-integration-state/advance-generation/index.ts delete mode 100644 hub/server/repositories/github-integration-state/constants.ts delete mode 100644 hub/server/repositories/github-integration-state/ensure-state/index.ts delete mode 100644 hub/server/repositories/github-integration-state/get-state/index.ts delete mode 100644 hub/server/repositories/github-integration-state/save-credentials/index.ts delete mode 100644 hub/server/repositories/github-integration-state/types.ts diff --git a/hub/.env.example b/hub/.env.example index 699dbd50..a7381621 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -14,7 +14,7 @@ DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica # Only database-enabled tests require this URL and CREATEDB permission. TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres -# Select the GitHub source-integration credential provider. See docs/identity.md. +# Select the GitHub source credential provider. See docs/identity.md. HUB_GITHUB_CREDENTIAL_PROVIDER=database # Generate separate values outside the database. See docs/identity.md. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 80efd4ea..81ffb9ce 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 [identity foundation](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. Sign-in routes, deployment, and product APIs have not been built. This app cannot yet authenticate users, evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity foundation](identity.md) separates Hub users, external identities, installation membership, authentication provider configuration, sessions, authorization attempts, security records, and GitHub source credentials. Sign-in routes, deployment, and product APIs have not been built. This app cannot yet authenticate users, evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. ## Backend diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 4a410ae1..bd9fcb49 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -47,6 +47,6 @@ The [shared Prisma client](database.md#shared-client) lives in `server/integrati Compose repository functions in domains. The identity store binds standalone queries to a database client or transaction there; a repository must not import its neighboring queries to build that store. -`staticIdentityStoreRecordId` identifies the row used to serialize identity-store transactions. It is not a user, provider configuration, or GitHub installation. Keep it with the identity-store state repository. +`staticIdentityStoreRecordId` identifies the row that serializes identity store transactions. It is not a user, provider configuration, or GitHub installation. Keep it with the identity store state repository. -GitHub source-integration credentials use a separate fixed state row. Authentication-provider configuration must not read or write that credential state. +GitHub source credentials use a separate fixed credentials row. Authentication provider configuration must not read or write those credentials. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 01e1e040..8dbd6f54 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,20 +1,22 @@ # Hub identity -Hub persists a provider-neutral identity foundation. User authentication proves an external identity. Hub installation membership decides whether that user belongs to this Hub and whether they are an administrator. Later authorization work will let Hub own prototype access independently of repository permissions. +Hub persists a provider-neutral identity foundation. User authentication proves an external identity. Hub installation membership records whether that user belongs to this Hub and whether they are an administrator. This persistence layer does not define prototype access rules. ## Persisted boundaries - `User` is the Hub-owned account. It does not contain a provider identifier or administrator flag. - `ExternalIdentity` links a provider, issuer, and subject to one Hub user. One user may link several identities. The issuer is part of uniqueness so separate OIDC tenants may reuse subjects safely. - `InstallationMembership` stores a user's status, role, and source for this Hub installation. -- `AuthenticationProvider` and its versioned revisions store provider-neutral authentication configuration. An OIDC-only configuration does not require GitHub source-integration credentials. -- `Session` records the Hub user, external identity, and authentication-provider revision that established it. GitHub source-integration credential rotation does not change session identity context. +- `AuthenticationProvider` and its versioned revisions store authentication configuration without tying it to one provider. An OIDC configuration does not require GitHub source credentials. +- `Session` records the Hub user, external identity, and authentication provider revision that established it. Rotating GitHub source credentials does not change session identity context. - `AuthorizationAttempt` stores digested state and browser bindings, a PKCE verifier, an optional OIDC nonce, provider revision, purpose, optional originating session, and expiry. Consumption deletes the record in the same transaction so concurrent callbacks cannot reuse it. -- `SecurityAudit` stores the event and minimal optional identity context. Audit records do not create the future prototype authorization model. +- `SecurityAudit` stores the event and minimal optional identity context. -Store operations can share one database transaction and lock a fixed store-state row. Related records and audit events therefore commit or roll back together, and overlapping mutations observe committed state. +Queries are available without a lock. Mutations run through one database transaction that locks a fixed store row. Related records and audit events therefore commit or roll back together, and overlapping mutations observe committed state. -## GitHub source-integration credentials +Security audit queries return at most 100 records from the previous 90 days. Each new audit write deletes records older than 90 days. Operators who require a longer security history must export records before that limit. + +## GitHub source credentials GitHub source access is an optional integration boundary, not the global identity boundary. Set `HUB_GITHUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): @@ -25,10 +27,10 @@ GitHub source access is an optional integration boundary, not the global identit Both providers currently validate an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. -Database credentials use AES-256-GCM with contextual additional authenticated data. Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors; Hub never falls back to another provider. +Database credentials use AES-256-GCM with contextual additional authenticated data. Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors. Hub never falls back to another provider. ## Foundation scope -This layer provides persistence only. It does not add HTTP sign-in routes, production OIDC or SCIM flows, prototype or product models, an authorization evaluator, GitHub permission synchronization, artifact ingestion, or UI. GitHub authentication configuration and GitHub source-integration credentials remain separate so either capability can change without redefining Hub identity. +This layer provides persistence only. It does not add HTTP sign-in routes, production OIDC or SCIM flows, prototype or product models, an authorization evaluator, GitHub permission synchronization, artifact ingestion, or UI. GitHub authentication configuration and GitHub source credentials remain separate so either capability can change without redefining Hub identity. Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index f15bfc29..b08d69cb 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -53,7 +53,7 @@ Use `.test.ts` or `.test.tsx` for ordinary cases. Use `.db.test.ts` or `.db.test Start the PostgreSQL service as described in [Development](development.md) and set `TEST_DATABASE_URL` using `.env.example`. The devcontainer supplies this variable. Use a local database user with permission to create and drop databases, never a production connection. -The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise external identity linking and uniqueness, installation membership, provider-aware one-use attempts, session context, GitHub integration credential independence, transaction rollback, and concurrent store locks. Job integration cases exercise real PostgreSQL queues, schedules, and database resets. +The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise external identity linking and uniqueness, installation membership, provider-specific authorization attempts, session context, independent GitHub credentials, transaction rollback, and concurrent store locks. Job integration cases exercise real PostgreSQL queues, schedules, and database resets. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. @@ -61,7 +61,7 @@ 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. 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. +3. Before every case, setup empties application and job tables across user schemas. 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. Unique database names also isolate simultaneous runs. Before clearing tables, cleanup checks that the database name belongs to the test namespace and matches the connected database. diff --git a/hub/schema/authentication-provider-revision.prisma b/hub/schema/authentication-provider-revision.prisma new file mode 100644 index 00000000..1d44e01f --- /dev/null +++ b/hub/schema/authentication-provider-revision.prisma @@ -0,0 +1,23 @@ +model AuthenticationProviderRevision { + // ID columns + id String @id @default(uuid()) @db.Uuid + authenticationProviderId String @map("authentication_provider_id") @db.Uuid + + // Relations + authenticationProvider AuthenticationProvider @relation(fields: [authenticationProviderId], references: [id], onDelete: Cascade) + authorizationAttempts AuthorizationAttempt[] + sessions Session[] + + // Attribute columns + revision Int + issuer String + configuration Json + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([authenticationProviderId, revision]) + @@index([issuer]) + @@map("authentication_provider_revisions") +} diff --git a/hub/schema/authentication-provider.prisma b/hub/schema/authentication-provider.prisma index 4e9b4a32..9bd0d29c 100644 --- a/hub/schema/authentication-provider.prisma +++ b/hub/schema/authentication-provider.prisma @@ -16,27 +16,3 @@ model AuthenticationProvider { @@map("authentication_providers") } - -model AuthenticationProviderRevision { - // ID columns - id String @id @default(uuid()) @db.Uuid - authenticationProviderId String @map("authentication_provider_id") @db.Uuid - - // Relations - authenticationProvider AuthenticationProvider @relation(fields: [authenticationProviderId], references: [id], onDelete: Cascade) - authorizationAttempts AuthorizationAttempt[] - sessions Session[] - - // Attribute columns - revision Int - issuer String - configuration Json - - // Timestamp columns - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) - - @@unique([authenticationProviderId, revision]) - @@index([issuer]) - @@map("authentication_provider_revisions") -} diff --git a/hub/schema/github-integration-state.prisma b/hub/schema/github-integration-credentials.prisma similarity index 73% rename from hub/schema/github-integration-state.prisma rename to hub/schema/github-integration-credentials.prisma index 7349ad5a..81b0ffe2 100644 --- a/hub/schema/github-integration-state.prisma +++ b/hub/schema/github-integration-credentials.prisma @@ -1,14 +1,13 @@ -model GitHubIntegrationState { +model GitHubIntegrationCredentials { // ID columns id String @id @db.Uuid // Attribute columns - generation Int @default(0) encryptedCredentials String? @map("encrypted_credentials") // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) - @@map("github_integration_states") + @@map("github_integration_credentials") } diff --git a/hub/schema/migrations/20261026195151_identity/migration.sql b/hub/schema/migrations/20261026195151_identity/migration.sql index 025776e7..f0ba238d 100644 --- a/hub/schema/migrations/20261026195151_identity/migration.sql +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -33,18 +33,17 @@ CREATE TABLE "identity_store_states" ( ); -- CreateTable -CREATE TABLE "github_integration_states" ( +CREATE TABLE "github_integration_credentials" ( "id" UUID NOT NULL, - "generation" INTEGER NOT NULL DEFAULT 0, "encrypted_credentials" TEXT, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "github_integration_states_pkey" PRIMARY KEY ("id") + CONSTRAINT "github_integration_credentials_pkey" PRIMARY KEY ("id") ); -- SeedData -INSERT INTO "github_integration_states" ("id") VALUES ('00000000-0000-4000-8000-000000000001'); +INSERT INTO "github_integration_credentials" ("id") VALUES ('00000000-0000-4000-8000-000000000001'); -- CreateTable CREATE TABLE "users" ( diff --git a/hub/server/domains/github-integration/create-credential-provider/index.test.ts b/hub/server/domains/github-integration/create-credential-provider/index.test.ts index c3a7c6ee..60b662bf 100644 --- a/hub/server/domains/github-integration/create-credential-provider/index.test.ts +++ b/hub/server/domains/github-integration/create-credential-provider/index.test.ts @@ -25,22 +25,20 @@ describe("createGitHubIntegrationCredentialProvider()", () => { const input = credentials() expect(provider.name).toBe("database") - expect(provider.read({ generation: 0, encryptedCredentials: null })).toBeNull() + expect(provider.read({ encryptedCredentials: null })).toBeNull() const encrypted = provider.encrypt!(input) expect(provider.encrypt!(input)).not.toBe(encrypted) - expect(provider.read({ generation: 0, encryptedCredentials: encrypted })).toEqual(input) + expect(provider.read({ encryptedCredentials: encrypted })).toEqual(input) expect(encrypted).not.toContain(input.clientSecret) expect(Buffer.from(encrypted, "base64").toString()).not.toContain(input.privateKey) const tampered = Buffer.from(encrypted, "base64") tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 1 - expect(() => - provider.read({ generation: 0, encryptedCredentials: tampered.toString("base64") }), - ).toThrow() + expect(() => provider.read({ encryptedCredentials: tampered.toString("base64") })).toThrow() const otherKey = createGitHubIntegrationCredentialProvider({ HUB_GITHUB_CREDENTIAL_KEY: "cd".repeat(32), }) - expect(() => otherKey.read({ generation: 0, encryptedCredentials: encrypted })).toThrow() + expect(() => otherKey.read({ encryptedCredentials: encrypted })).toThrow() }) it("requires a valid encryption key outside the database", () => { @@ -64,9 +62,7 @@ describe("createGitHubIntegrationCredentialProvider()", () => { expect(provider.name).toBe("environment") expect(provider.encrypt).toBeUndefined() - expect( - provider.read({ generation: 9, encryptedCredentials: "not-database-credentials" }), - ).toEqual(input) + expect(provider.read({ encryptedCredentials: "not-database-credentials" })).toEqual(input) }) it("rejects incomplete environment configuration and unknown providers instead of falling back", () => { diff --git a/hub/server/domains/github-integration/create-credential-provider/types.ts b/hub/server/domains/github-integration/create-credential-provider/types.ts index ee46be51..df7e7068 100644 --- a/hub/server/domains/github-integration/create-credential-provider/types.ts +++ b/hub/server/domains/github-integration/create-credential-provider/types.ts @@ -1,8 +1,8 @@ import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" -import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" +import type { GitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/types" export interface GitHubIntegrationCredentialProvider { name: "database" | "environment" - read(state: GitHubIntegrationState): GitHubCredentials | null + read(state: GitHubIntegrationCredentials): GitHubCredentials | null encrypt?: (credentials: GitHubCredentials) => string } diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 82411d9c..4b076a1f 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -3,9 +3,8 @@ import { consumeAuthorizationAttempt } from "#server/repositories/authorization- import { createAuthorizationAttempt } from "#server/repositories/authorization-attempts/create-attempt" import { getExternalIdentity } from "#server/repositories/external-identities/get-external-identity" import { saveExternalIdentity } from "#server/repositories/external-identities/save-external-identity" -import { advanceGitHubIntegrationGeneration } from "#server/repositories/github-integration-state/advance-generation" -import { getGitHubIntegrationState } from "#server/repositories/github-integration-state/get-state" -import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-state/save-credentials" +import { getGitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/get-credentials" +import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/save-credentials" import { getInstallationMembership } from "#server/repositories/installation-memberships/get-membership" import { saveInstallationMembership } from "#server/repositories/installation-memberships/save-membership" import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" @@ -25,9 +24,8 @@ import type { Prisma } from "../../../generated/prisma/client.ts" export function createIdentityRecords(database: Prisma.TransactionClient): IdentityRecords { return { - githubIntegrationState: getGitHubIntegrationState.bind(null, database), + githubIntegrationCredentials: getGitHubIntegrationCredentials.bind(null, database), saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), - advanceGitHubIntegrationGeneration: advanceGitHubIntegrationGeneration.bind(null, database), user: getUser.bind(null, database), createUser: createUser.bind(null, database), externalIdentity: getExternalIdentity.bind(null, database), diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts index 06d2f2d6..4ab2035c 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -58,6 +58,20 @@ async function createSession(store: IdentityStore, suffix: string) { } describe("createIdentityStore()", () => { + it("exposes queries without exposing mutations outside the store lock", async () => { + const { store } = await database() + + expect(Object.keys(store.read).sort()).toEqual([ + "audits", + "externalIdentity", + "githubIntegrationCredentials", + "membership", + "session", + "sessionById", + "user", + ]) + }) + it("rolls back GitHub integration, session, and audit changes together", async () => { const { store } = await database() const { session } = await createSession(store, "rollback") @@ -68,8 +82,8 @@ describe("createIdentityStore()", () => { await expect( store.lock(async (records) => { await records.saveGitHubIntegrationCredentials("replacement-encrypted-fixture") - await records.advanceGitHubIntegrationGeneration() await records.deleteSessions() + await expect(records.sessionById(session.id)).resolves.toBeNull() await records.audit({ event: "session.revoke_all", actorUserId: null, @@ -80,19 +94,19 @@ describe("createIdentityStore()", () => { }), ).rejects.toThrow("Transaction failed") - expect(await store.read.githubIntegrationState()).toMatchObject({ - generation: 0, + expect(await store.read.githubIntegrationCredentials()).toMatchObject({ encryptedCredentials: "initial-encrypted-fixture", }) expect(await store.read.sessionById(session.id)).not.toBeNull() expect(await store.read.audits()).toEqual([]) }) - it("serializes locks so an overlapping mutation reads the committed generation", async () => { - const { store } = await database() + it("blocks an overlapping mutation until it can read the committed credentials", async () => { + const { prisma, store } = await database() await store.lock(() => Promise.resolve()) let entered!: () => void let release!: () => void + let secondEntered = false const started = new Promise((resolve) => { entered = resolve }) @@ -100,21 +114,48 @@ describe("createIdentityStore()", () => { release = resolve }) const first = store.lock(async (records) => { - await records.advanceGitHubIntegrationGeneration() + await records.saveGitHubIntegrationCredentials("first-encrypted-fixture") entered() await gate }) await started const second = store.lock(async (records) => { - const state = await records.githubIntegrationState() - await records.advanceGitHubIntegrationGeneration() - return state.generation + secondEntered = true + const credentials = await records.githubIntegrationCredentials() + await records.saveGitHubIntegrationCredentials("second-encrypted-fixture") + return credentials.encryptedCredentials + }) + onTestFinished(() => { + release() }) - release() + + try { + await vi.waitFor( + async () => { + const [waiting] = await prisma.$queryRaw<{ waitEventType: string | null }[]>` + SELECT wait_event_type AS "waitEventType" + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND state = 'active' + AND query LIKE '%identity_store_states%' + AND wait_event_type = 'Lock' + LIMIT 1 + ` + expect(waiting?.waitEventType).toBe("Lock") + }, + { interval: 20, timeout: 5_000 }, + ) + expect(secondEntered).toBe(false) + } finally { + release() + } await first - expect(await second).toBe(1) - expect((await store.read.githubIntegrationState()).generation).toBe(2) + expect(await second).toBe("first-encrypted-fixture") + expect(await store.read.githubIntegrationCredentials()).toMatchObject({ + encryptedCredentials: "second-encrypted-fixture", + }) }) it("links multiple issuer-scoped external identities without allowing a relink", async () => { @@ -234,8 +275,7 @@ describe("createIdentityStore()", () => { enabled: true, }, }) - expect(await store.read.githubIntegrationState()).toMatchObject({ - generation: 0, + expect(await store.read.githubIntegrationCredentials()).toMatchObject({ encryptedCredentials: null, }) @@ -270,10 +310,9 @@ describe("createIdentityStore()", () => { "credential-rotation", ) - await store.lock(async (records) => { - await records.saveGitHubIntegrationCredentials("rotated-encrypted-credentials") - await records.advanceGitHubIntegrationGeneration() - }) + await store.lock((records) => + records.saveGitHubIntegrationCredentials("rotated-encrypted-credentials"), + ) expect(await store.read.session(session.tokenDigest)).toMatchObject({ id: session.id, diff --git a/hub/server/domains/identity/create-store/index.ts b/hub/server/domains/identity/create-store/index.ts index 73da355b..880a2f9b 100644 --- a/hub/server/domains/identity/create-store/index.ts +++ b/hub/server/domains/identity/create-store/index.ts @@ -1,12 +1,23 @@ import { createIdentityRecords } from "#server/domains/identity/create-records" import { withIdentityStoreLock } from "#server/domains/identity/with-store-lock" -import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { IdentityQueries, IdentityStore } from "#server/domains/identity/create-store/types" import type { PrismaClient } from "../../../generated/prisma/client.ts" export function createIdentityStore(client: PrismaClient): IdentityStore { + const records = createIdentityRecords(client) + const read: IdentityQueries = { + githubIntegrationCredentials: () => records.githubIntegrationCredentials(), + user: (id) => records.user(id), + externalIdentity: (provider, issuer, subject) => + records.externalIdentity(provider, issuer, subject), + membership: (userId) => records.membership(userId), + session: (tokenDigest) => records.session(tokenDigest), + sessionById: (id) => records.sessionById(id), + audits: () => records.audits(), + } return { - read: createIdentityRecords(client), + read, lock: (operation) => withIdentityStoreLock(client, operation), } } diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 378667d2..360677d5 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -4,7 +4,7 @@ import type { ExternalIdentityInput, HubExternalIdentity, } from "#server/repositories/external-identities/types" -import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" +import type { GitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/types" import type { InstallationMembershipInput, InstallationMembershipRecord, @@ -34,9 +34,8 @@ export interface SessionContext extends HubSession { } export interface IdentityRecords { - githubIntegrationState(): Promise + githubIntegrationCredentials(): Promise saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise - advanceGitHubIntegrationGeneration(): Promise user(id: string): Promise createUser(input: UserInput): Promise externalIdentity( @@ -65,7 +64,21 @@ export interface IdentityRecords { audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> } +export interface IdentityQueries { + githubIntegrationCredentials(): Promise + user(id: string): Promise + externalIdentity( + provider: string, + issuer: string, + subject: string, + ): Promise<(HubExternalIdentity & { user: HubUser }) | null> + membership(userId: string): Promise + session(tokenDigest: string): Promise + sessionById(id: string): Promise + audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> +} + export interface IdentityStore { - read: IdentityRecords + read: IdentityQueries lock(operation: (records: IdentityRecords) => Promise): Promise } diff --git a/hub/server/domains/identity/with-store-lock/index.ts b/hub/server/domains/identity/with-store-lock/index.ts index bcdabd78..42e8665a 100644 --- a/hub/server/domains/identity/with-store-lock/index.ts +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -1,5 +1,5 @@ import { createIdentityRecords } from "#server/domains/identity/create-records" -import { ensureGitHubIntegrationState } from "#server/repositories/github-integration-state/ensure-state" +import { ensureGitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/ensure-credentials" import { withIdentityStoreLock as withRepositoryLock } from "#server/repositories/identity-store-state/with-store-lock" import type { IdentityRecords } from "#server/domains/identity/create-store/types" @@ -10,7 +10,7 @@ export function withIdentityStoreLock( operation: (records: IdentityRecords) => Promise, ): Promise { return withRepositoryLock(client, async (database) => { - await ensureGitHubIntegrationState(database) + await ensureGitHubIntegrationCredentials(database) return operation(createIdentityRecords(database)) }) } diff --git a/hub/server/integrations/github/decrypt-github-credentials/index.test.ts b/hub/server/integrations/github/decrypt-github-credentials/index.test.ts new file mode 100644 index 00000000..a7fc069f --- /dev/null +++ b/hub/server/integrations/github/decrypt-github-credentials/index.test.ts @@ -0,0 +1,51 @@ +import { createCipheriv } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { decryptGitHubCredentials } from "#server/integrations/github/decrypt-github-credentials" +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import { encryptGitHubCredentials } from "#server/integrations/github/encrypt-github-credentials" +import { GitHubError } from "#server/integrations/github/github-error" + +function encryptPlaintext(key: Buffer, plaintext: string) { + const nonce = Buffer.alloc(12) + const cipher = createCipheriv("aes-256-gcm", key, nonce) + cipher.setAAD(credentialContext) + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) + return Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString("base64") +} + +describe("decryptGitHubCredentials()", () => { + it("preserves credential validation errors after decrypting and parsing credentials", () => { + const key = Buffer.alloc(32) + const encrypted = encryptGitHubCredentials(key, { + appId: "12345", + installationId: "7", + clientId: "", + clientSecret: "synthetic-client-secret", + privateKey: "not-used-after-schema-validation", + }) + + let error: unknown + try { + decryptGitHubCredentials(key, encrypted) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(GitHubError) + expect(error).toMatchObject({ + code: "invalid_credentials", + message: + "Supply the GitHub App ID, installation ID, client ID, client secret, and RSA private key.", + }) + }) + + it.each([ + ["malformed ciphertext", "not-ciphertext"], + ["malformed JSON", encryptPlaintext(Buffer.alloc(32), "{")], + ])("reports %s as unreadable credentials", (_description, encrypted) => { + expect(() => decryptGitHubCredentials(Buffer.alloc(32), encrypted)).toThrow( + "Cannot decrypt GitHub App credentials. Restore the configured encryption key.", + ) + }) +}) diff --git a/hub/server/integrations/github/decrypt-github-credentials/index.ts b/hub/server/integrations/github/decrypt-github-credentials/index.ts index d25b1c18..a62aeacc 100644 --- a/hub/server/integrations/github/decrypt-github-credentials/index.ts +++ b/hub/server/integrations/github/decrypt-github-credentials/index.ts @@ -9,14 +9,16 @@ export function decryptGitHubCredentials( encryptedCredentials: string | null, ): GitHubCredentials | null { if (encryptedCredentials === null) return null + let parsedCredentials: unknown try { const data = Buffer.from(encryptedCredentials, "base64") const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12)) decipher.setAAD(credentialContext) decipher.setAuthTag(data.subarray(12, 28)) const plaintext = Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]) - return validateGitHubCredentials(JSON.parse(plaintext.toString("utf8"))) + parsedCredentials = JSON.parse(plaintext.toString("utf8")) } catch { throw new Error("Cannot decrypt GitHub App credentials. Restore the configured encryption key.") } + return validateGitHubCredentials(parsedCredentials) } diff --git a/hub/server/repositories/github-integration-credentials/constants.ts b/hub/server/repositories/github-integration-credentials/constants.ts new file mode 100644 index 00000000..7d3486c6 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process reads and writes the same GitHub integration credential record. +export const staticGitHubIntegrationCredentialsId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/github-integration-credentials/ensure-credentials/index.ts b/hub/server/repositories/github-integration-credentials/ensure-credentials/index.ts new file mode 100644 index 00000000..0d70f1c9 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/ensure-credentials/index.ts @@ -0,0 +1,11 @@ +import { staticGitHubIntegrationCredentialsId } from "#server/repositories/github-integration-credentials/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function ensureGitHubIntegrationCredentials(database: Prisma.TransactionClient) { + await database.gitHubIntegrationCredentials.upsert({ + where: { id: staticGitHubIntegrationCredentialsId }, + create: { id: staticGitHubIntegrationCredentialsId }, + update: {}, + }) +} diff --git a/hub/server/repositories/github-integration-credentials/get-credentials/index.ts b/hub/server/repositories/github-integration-credentials/get-credentials/index.ts new file mode 100644 index 00000000..8ffdd137 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/get-credentials/index.ts @@ -0,0 +1,9 @@ +import { staticGitHubIntegrationCredentialsId } from "#server/repositories/github-integration-credentials/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getGitHubIntegrationCredentials(database: Prisma.TransactionClient) { + return database.gitHubIntegrationCredentials.findUniqueOrThrow({ + where: { id: staticGitHubIntegrationCredentialsId }, + }) +} diff --git a/hub/server/repositories/github-integration-credentials/save-credentials/index.ts b/hub/server/repositories/github-integration-credentials/save-credentials/index.ts new file mode 100644 index 00000000..df115a67 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/save-credentials/index.ts @@ -0,0 +1,14 @@ +import { staticGitHubIntegrationCredentialsId } from "#server/repositories/github-integration-credentials/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveGitHubIntegrationCredentials( + database: Prisma.TransactionClient, + encryptedCredentials: string, +) { + await database.gitHubIntegrationCredentials.upsert({ + where: { id: staticGitHubIntegrationCredentialsId }, + create: { id: staticGitHubIntegrationCredentialsId, encryptedCredentials }, + update: { encryptedCredentials }, + }) +} diff --git a/hub/server/repositories/github-integration-credentials/types.ts b/hub/server/repositories/github-integration-credentials/types.ts new file mode 100644 index 00000000..4fddf915 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/types.ts @@ -0,0 +1,3 @@ +export interface GitHubIntegrationCredentials { + encryptedCredentials: string | null +} diff --git a/hub/server/repositories/github-integration-state/advance-generation/index.ts b/hub/server/repositories/github-integration-state/advance-generation/index.ts deleted file mode 100644 index 92420a95..00000000 --- a/hub/server/repositories/github-integration-state/advance-generation/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export async function advanceGitHubIntegrationGeneration(database: Prisma.TransactionClient) { - await database.gitHubIntegrationState.upsert({ - where: { id: staticGitHubIntegrationRecordId }, - create: { id: staticGitHubIntegrationRecordId, generation: 1 }, - update: { generation: { increment: 1 } }, - }) -} diff --git a/hub/server/repositories/github-integration-state/constants.ts b/hub/server/repositories/github-integration-state/constants.ts deleted file mode 100644 index b86d5af4..00000000 --- a/hub/server/repositories/github-integration-state/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Every Hub process must lock the same row before changing GitHub integration credentials. -export const staticGitHubIntegrationRecordId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/github-integration-state/ensure-state/index.ts b/hub/server/repositories/github-integration-state/ensure-state/index.ts deleted file mode 100644 index c3a1116f..00000000 --- a/hub/server/repositories/github-integration-state/ensure-state/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export async function ensureGitHubIntegrationState(database: Prisma.TransactionClient) { - await database.gitHubIntegrationState.upsert({ - where: { id: staticGitHubIntegrationRecordId }, - create: { id: staticGitHubIntegrationRecordId }, - update: {}, - }) -} diff --git a/hub/server/repositories/github-integration-state/get-state/index.ts b/hub/server/repositories/github-integration-state/get-state/index.ts deleted file mode 100644 index 1107a6cf..00000000 --- a/hub/server/repositories/github-integration-state/get-state/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export function getGitHubIntegrationState(database: Prisma.TransactionClient) { - return database.gitHubIntegrationState.findUniqueOrThrow({ - where: { id: staticGitHubIntegrationRecordId }, - }) -} diff --git a/hub/server/repositories/github-integration-state/save-credentials/index.ts b/hub/server/repositories/github-integration-state/save-credentials/index.ts deleted file mode 100644 index e053f527..00000000 --- a/hub/server/repositories/github-integration-state/save-credentials/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" - -import type { Prisma } from "../../../generated/prisma/client.ts" - -export async function saveGitHubIntegrationCredentials( - database: Prisma.TransactionClient, - encryptedCredentials: string, -) { - await database.gitHubIntegrationState.upsert({ - where: { id: staticGitHubIntegrationRecordId }, - create: { id: staticGitHubIntegrationRecordId, encryptedCredentials }, - update: { encryptedCredentials }, - }) -} diff --git a/hub/server/repositories/github-integration-state/types.ts b/hub/server/repositories/github-integration-state/types.ts deleted file mode 100644 index b26f045a..00000000 --- a/hub/server/repositories/github-integration-state/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GitHubIntegrationState { - generation: number - encryptedCredentials: string | null -}