From 047385495c31f0a1256675e224c6a42d9fc03443 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 01/56] 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 35e842d8..a0c2ce72 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -6,3 +6,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 a844c507..35506984 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -7,6 +7,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Coding conventions](coding-conventions.md): frontend layers and interface rules. - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. +- [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 de76ecfe..052ac579 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, background jobs, 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, background jobs, 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 46cc8f0b..88277750 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -30,6 +30,14 @@ Put each function in a directory named with lowercase words and hyphens, such as 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 6cb52918..5604851b 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. [Compose](../.devcontainer/compose.yaml) pins the development database image. [package.json](../package.json) pins the Prisma packages to the same version. Dependency updates must follow the [seven-day dependency policy](../../docs/testing-and-review.md#update-dependencies). diff --git a/hub/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 4730dc20..e690baf8 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. There are no database test cases yet; an empty run does not prepare or exercise the databases. +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. 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 ba09fc60..3e9bbbef 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" }, @@ -22,7 +36,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 8762ec524be31c38534c63447d220a947ad88e61 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 12:20:38 -0700 Subject: [PATCH 02/56] 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 | 2 +- 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, 1010 insertions(+), 366 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 55c4363b..3086c100 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -10,13 +10,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 d3b02f17..901669d8 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, background jobs, 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, background jobs, 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 5604851b..f77a896c 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. [Compose](../.devcontainer/compose.yaml) pins the development database image. [package.json](../package.json) pins the Prisma packages to the same version. Dependency updates must follow the [seven-day dependency policy](../../docs/testing-and-review.md#update-dependencies). diff --git a/hub/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 e690baf8..d637b72a 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 persistence, one-use attempts, transaction rollback, and concurrent revocation locks. +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. 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 9e734c9b3d8e38a312b5800e3b5f516f986e1f46 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 12:31:13 -0700 Subject: [PATCH 03/56] 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 d6a6ebf508374fd5065abb5da3febed0f45de337 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 12:35:38 -0700 Subject: [PATCH 04/56] 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 506611c7ad74d6fdab256ca375568acf1ae0558f Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 12:58:43 -0700 Subject: [PATCH 05/56] feat(hub): add GitHub user authentication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 13 +- hub/README.md | 4 +- .../application/authentication/index.tsx | 79 ++++ .../javascript/entrypoints/application.tsx | 4 +- .../pages/sign-in-page/index.test.tsx | 57 +++ .../javascript/pages/sign-in-page/index.tsx | 60 +++ .../javascript/pages/sign-in-page/stories.tsx | 34 ++ .../javascript/pages/sign-in-page/styles.css | 15 + .../javascript/pages/sign-in-page/types.ts | 14 + hub/docs/README.md | 2 +- hub/docs/architecture.md | 8 +- hub/docs/development.md | 6 +- hub/docs/identity.md | 22 +- hub/docs/logging.md | 2 + hub/docs/testing-and-operations.md | 4 +- hub/schema/authorization-attempt.prisma | 1 + hub/schema/identity-store-state.prisma | 3 + .../20261026195151_identity/migration.sql | 2 + hub/server/create-app/index.ts | 8 +- .../complete-github-authorization/index.ts | 156 ++++++++ .../create-admission-policy/index.ts | 42 ++ .../authentication/create-runtime/index.ts | 77 ++++ .../create-service/index.db.test.ts | 124 ++++++ .../create-service/index.test.ts | 365 ++++++++++++++++++ .../authentication/create-service/index.ts | 25 ++ .../authentication/create-service/types.ts | 49 +++ .../ensure-provider-revision/index.ts | 50 +++ .../get-current-session/index.ts | 17 + .../authentication/revoke-sessions/index.ts | 36 ++ .../domains/authentication/sign-out/index.ts | 18 + .../start-github-authorization/index.ts | 57 +++ .../domains/identity/create-records/index.ts | 13 + .../domains/identity/create-store/types.ts | 14 +- hub/server/index.ts | 5 +- .../express/create-rate-limit/index.ts | 37 ++ .../prepare-authentication-request/index.ts | 21 + .../respond-authentication-error/index.ts | 26 ++ .../check-repository-admission/constants.ts | 10 + .../check-repository-admission/index.ts | 57 +++ .../github/create-app-token/index.ts | 16 + .../create-installation-token/constants.ts | 3 + .../github/create-installation-token/index.ts | 13 + .../create-user-auth-client/index.test.ts | 153 ++++++++ .../github/create-user-auth-client/index.ts | 17 + .../github/create-user-auth-client/types.ts | 18 + .../create-user-authorization-url/index.ts | 19 + .../exchange-user-authorization/constants.ts | 6 + .../exchange-user-authorization/index.ts | 42 ++ .../github/github-api-error/index.ts | 5 + .../github/request-github-api/index.ts | 25 ++ .../github/request-github-user/constants.ts | 8 + .../github/request-github-user/index.ts | 39 ++ .../get-current-revision/index.ts | 12 + .../get-revision/index.ts | 8 + .../consume-attempt/index.ts | 2 + .../create-attempt/index.ts | 4 +- .../authorization-attempts/types.ts | 1 + .../advance-session-generation/index.ts | 10 + .../identity-store-state/get-state/index.ts | 10 + .../identity-store-state/types.ts | 3 + .../repositories/users/update-user/index.ts | 7 + .../api/auth/github/callback/get/index.ts | 49 +++ .../routes/api/auth/github/post/index.ts | 24 ++ hub/server/routes/api/session/delete/index.ts | 26 ++ hub/server/routes/api/session/get/index.ts | 50 +++ .../api/session/revoke-all/post/index.ts | 21 + .../routes/api/session/verify/post/index.ts | 27 ++ hub/server/routes/authentication.test.ts | 272 +++++++++++++ hub/server/routes/index.ts | 43 ++- hub/server/routes/types.ts | 9 + .../utils/authentication-cookies/index.ts | 20 + .../utils/authentication-error/index.ts | 9 + hub/server/utils/digest-secret/index.ts | 5 + hub/server/utils/read-cookie/index.ts | 8 + hub/server/utils/require-csrf-token/index.ts | 8 + hub/server/utils/secrets-match/index.ts | 7 + 76 files changed, 2511 insertions(+), 25 deletions(-) create mode 100644 hub/app/javascript/application/authentication/index.tsx create mode 100644 hub/app/javascript/pages/sign-in-page/index.test.tsx create mode 100644 hub/app/javascript/pages/sign-in-page/index.tsx create mode 100644 hub/app/javascript/pages/sign-in-page/stories.tsx create mode 100644 hub/app/javascript/pages/sign-in-page/styles.css create mode 100644 hub/app/javascript/pages/sign-in-page/types.ts create mode 100644 hub/server/domains/authentication/complete-github-authorization/index.ts create mode 100644 hub/server/domains/authentication/create-admission-policy/index.ts create mode 100644 hub/server/domains/authentication/create-runtime/index.ts create mode 100644 hub/server/domains/authentication/create-service/index.db.test.ts create mode 100644 hub/server/domains/authentication/create-service/index.test.ts create mode 100644 hub/server/domains/authentication/create-service/index.ts create mode 100644 hub/server/domains/authentication/create-service/types.ts create mode 100644 hub/server/domains/authentication/ensure-provider-revision/index.ts create mode 100644 hub/server/domains/authentication/get-current-session/index.ts create mode 100644 hub/server/domains/authentication/revoke-sessions/index.ts create mode 100644 hub/server/domains/authentication/sign-out/index.ts create mode 100644 hub/server/domains/authentication/start-github-authorization/index.ts create mode 100644 hub/server/integrations/express/create-rate-limit/index.ts create mode 100644 hub/server/integrations/express/prepare-authentication-request/index.ts create mode 100644 hub/server/integrations/express/respond-authentication-error/index.ts create mode 100644 hub/server/integrations/github/check-repository-admission/constants.ts create mode 100644 hub/server/integrations/github/check-repository-admission/index.ts create mode 100644 hub/server/integrations/github/create-app-token/index.ts create mode 100644 hub/server/integrations/github/create-installation-token/constants.ts create mode 100644 hub/server/integrations/github/create-installation-token/index.ts create mode 100644 hub/server/integrations/github/create-user-auth-client/index.test.ts create mode 100644 hub/server/integrations/github/create-user-auth-client/index.ts create mode 100644 hub/server/integrations/github/create-user-auth-client/types.ts create mode 100644 hub/server/integrations/github/create-user-authorization-url/index.ts create mode 100644 hub/server/integrations/github/exchange-user-authorization/constants.ts create mode 100644 hub/server/integrations/github/exchange-user-authorization/index.ts create mode 100644 hub/server/integrations/github/github-api-error/index.ts create mode 100644 hub/server/integrations/github/request-github-api/index.ts create mode 100644 hub/server/integrations/github/request-github-user/constants.ts create mode 100644 hub/server/integrations/github/request-github-user/index.ts create mode 100644 hub/server/repositories/authentication-providers/get-current-revision/index.ts create mode 100644 hub/server/repositories/authentication-providers/get-revision/index.ts create mode 100644 hub/server/repositories/identity-store-state/advance-session-generation/index.ts create mode 100644 hub/server/repositories/identity-store-state/get-state/index.ts create mode 100644 hub/server/repositories/identity-store-state/types.ts create mode 100644 hub/server/repositories/users/update-user/index.ts create mode 100644 hub/server/routes/api/auth/github/callback/get/index.ts create mode 100644 hub/server/routes/api/auth/github/post/index.ts create mode 100644 hub/server/routes/api/session/delete/index.ts create mode 100644 hub/server/routes/api/session/get/index.ts create mode 100644 hub/server/routes/api/session/revoke-all/post/index.ts create mode 100644 hub/server/routes/api/session/verify/post/index.ts create mode 100644 hub/server/routes/authentication.test.ts create mode 100644 hub/server/routes/types.ts create mode 100644 hub/server/utils/authentication-cookies/index.ts create mode 100644 hub/server/utils/authentication-error/index.ts create mode 100644 hub/server/utils/digest-secret/index.ts create mode 100644 hub/server/utils/read-cookie/index.ts create mode 100644 hub/server/utils/require-csrf-token/index.ts create mode 100644 hub/server/utils/secrets-match/index.ts diff --git a/hub/.env.example b/hub/.env.example index 3086c100..59b34228 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -4,19 +4,30 @@ PORT=3001 # API logging threshold: trace, debug, info, warn, error, fatal, or silent. LOG_LEVEL=info +# Public browser origin. Production requires HTTPS; local HTTP must use loopback. +HUB_ORIGIN=http://127.0.0.1:5173 + # Local development only. The devcontainer supplies its own database hostname. 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 +# GitHub user authorization is optional until both values are configured. +# These credentials establish user identity and are separate from source integration. +# HUB_GITHUB_AUTH_CLIENT_ID= +# HUB_GITHUB_AUTH_CLIENT_SECRET= + +# GitHub proves identity by default. Select legacy-repository only for compatibility. +HUB_ADMISSION_POLICY=hub-managed + # 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_GITHUB_CREDENTIAL_KEY= -# The environment provider is read-only and requires every GitHub integration setting below. +# The environment provider is read-only and requires every source-integration setting below. # GITHUB_APP_ID= # GITHUB_INSTALLATION_ID= # GITHUB_CLIENT_ID= diff --git a/hub/README.md b/hub/README.md index fba50194..b6bfa5d8 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,9 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API has one route: `GET /api/health`. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API. +The API exposes its health check and GitHub authentication and session routes. The frontend provides the minimal GitHub sign-in and sign-out surface with light and dark themes. Existing components, features, and pages remain in Storybook, but they are not connected to product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. Sign-in, saved data, live updates, background jobs, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. GitHub sign-in works when its dedicated client credentials are configured; invitations, membership administration, product authorization, saved product data, live updates, background jobs, and deployment are not implemented. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx new file mode 100644 index 00000000..cbffc8e0 --- /dev/null +++ b/hub/app/javascript/application/authentication/index.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react" + +import { SignInPage } from "@pages/sign-in-page" +import type { SignInPageUser } from "@pages/sign-in-page/types" + +interface SessionResponse { + authenticated: boolean + configured: boolean + csrfToken: string + user?: SignInPageUser +} + +async function fetchSession(): Promise { + const response = await fetch("/api/session", { credentials: "same-origin" }) + if (!response.ok) throw new Error("Session request failed") + return (await response.json()) as SessionResponse +} + +export function AuthenticationApplication() { + const [session, setSession] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchSession() + .then(setSession) + .catch(() => setError("Hub could not load your sign-in status.")) + .finally(() => setLoading(false)) + }, []) + + async function signIn() { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch("/api/auth/github", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!response.ok) throw new Error("Sign-in request failed") + const data = (await response.json()) as { authorizationUrl: string } + window.location.assign(data.authorizationUrl) + } catch { + setError("Hub could not start GitHub sign-in.") + setLoading(false) + } + } + + async function signOut() { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch("/api/session", { + method: "DELETE", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!response.ok) throw new Error("Sign-out request failed") + setSession(await fetchSession()) + } catch { + setError("Hub could not sign you out.") + } finally { + setLoading(false) + } + } + + return ( + void signIn()} + onSignOut={() => void signOut()} + /> + ) +} diff --git a/hub/app/javascript/entrypoints/application.tsx b/hub/app/javascript/entrypoints/application.tsx index ca589d06..fe075efb 100644 --- a/hub/app/javascript/entrypoints/application.tsx +++ b/hub/app/javascript/entrypoints/application.tsx @@ -2,7 +2,7 @@ import "@/tokens/base.css" import { StrictMode } from "react" import { createRoot } from "react-dom/client" -import { ReplicaLogo } from "@components/replica-logo" +import { AuthenticationApplication } from "@/application/authentication" import { HubThemeProvider } from "@hooks/use-hub-theme" const root = document.getElementById("root") @@ -11,7 +11,7 @@ if (!root) throw new Error("The Hub document is missing its React root.") createRoot(root).render( - + , ) diff --git a/hub/app/javascript/pages/sign-in-page/index.test.tsx b/hub/app/javascript/pages/sign-in-page/index.test.tsx new file mode 100644 index 00000000..d2d9ea47 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/index.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { SignInPage } from "." + +describe("SignInPage()", () => { + it("starts GitHub sign-in without presenting repository access as authentication", () => { + const onSignIn = vi.fn() + render( + undefined} + />, + ) + + fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" })) + + expect(onSignIn).toHaveBeenCalledOnce() + expect(screen.getByText(/Repository access is authorized separately/)).toBeVisible() + }) + + it("shows the provider-neutral session identity and sign-out control", () => { + render( + undefined} + onSignOut={() => undefined} + />, + ) + + expect(screen.getByText(/Signed in as/)).toHaveTextContent("Mona Lisa (@mona)") + expect(screen.getByRole("button", { name: "Sign out" })).toBeEnabled() + }) + + it("disables sign-in when GitHub user authorization is not configured", () => { + render( + undefined} + onSignOut={() => undefined} + />, + ) + + expect(screen.getByRole("button", { name: "Sign in with GitHub" })).toBeDisabled() + expect(screen.getByText(/has not been configured/)).toBeVisible() + }) +}) diff --git a/hub/app/javascript/pages/sign-in-page/index.tsx b/hub/app/javascript/pages/sign-in-page/index.tsx new file mode 100644 index 00000000..15907c79 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/index.tsx @@ -0,0 +1,60 @@ +import { Button, Heading, Stack, Text } from "@primer/react-brand" + +import { ReplicaLogo } from "@components/replica-logo" + +import type { SignInPageProps } from "./types" +import "./styles.css" + +export function SignInPage({ + configured, + loading, + user, + error, + onSignIn, + onSignOut, +}: SignInPageProps) { + return ( +
+ + + + Replica Hub + + {user ? ( + <> + + Signed in as {user.displayName ?? user.username} + {user.username ? ` (@${user.username})` : ""}. + + + + ) : ( + <> + + Sign in with GitHub to create a Hub session. Repository access is authorized + separately. + + + {!configured && ( + GitHub sign-in has not been configured for this Hub. + )} + + )} + {error && ( + + {error} + + )} + +
+ ) +} diff --git a/hub/app/javascript/pages/sign-in-page/stories.tsx b/hub/app/javascript/pages/sign-in-page/stories.tsx new file mode 100644 index 00000000..2e0baf07 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { SignInPage } from "." + +const meta = { + title: "Hub/Pages/Identity/Sign in", + component: SignInPage, + parameters: { layout: "fullscreen" }, + args: { + configured: true, + loading: false, + user: null, + error: null, + onSignIn: () => undefined, + onSignOut: () => undefined, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const SignedOut: Story = {} +export const SignedIn: Story = { + args: { + user: { + displayName: "Mona Lisa", + username: "mona", + avatarUrl: null, + }, + }, +} +export const Unconfigured: Story = { + args: { configured: false }, +} diff --git a/hub/app/javascript/pages/sign-in-page/styles.css b/hub/app/javascript/pages/sign-in-page/styles.css new file mode 100644 index 00000000..77bb16d8 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/styles.css @@ -0,0 +1,15 @@ +.sign-in-page { + display: grid; + min-block-size: 100vh; + place-items: center; + padding: 2rem; + text-align: center; +} + +.sign-in-page > * { + max-inline-size: 32rem; +} + +.sign-in-page__error { + color: var(--hub-fg-danger); +} diff --git a/hub/app/javascript/pages/sign-in-page/types.ts b/hub/app/javascript/pages/sign-in-page/types.ts new file mode 100644 index 00000000..d12b098c --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/types.ts @@ -0,0 +1,14 @@ +export interface SignInPageUser { + displayName: string | null + username: string | null + avatarUrl: string | null +} + +export interface SignInPageProps { + configured: boolean + loading: boolean + user: SignInPageUser | null + error: string | null + onSignIn: () => void + onSignOut: () => void +} diff --git a/hub/docs/README.md b/hub/docs/README.md index 85db3214..069c4073 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -7,7 +7,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Coding conventions](coding-conventions.md): frontend layers and interface rules. - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. -- [Identity](identity.md): credential storage and providers. +- [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 901669d8..52f58790 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -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. +The API responds to a health check and provides GitHub authentication and Hub session routes. The frontend displays the sign-in state 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, background jobs, 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 system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization and provider-neutral Hub sessions work when user-auth credentials are configured. Deployment, background jobs, and product APIs have not been built. This app cannot yet evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -The only route is `GET /api/health`, which returns `{ "status": "ok" }`. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` starts it at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication runtime and starts the API at a local address. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. @@ -35,7 +35,7 @@ Keep the existing `app/javascript/` layout. Application code starts React and wi Imports follow the arrows above. Shared components, hooks, and utilities must not import pages, features, or application code. Keep browser and server code separate. Agree on the API before adding types shared between them. -The frontend displays the existing logo inside the theme provider. Existing pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. +The frontend displays the GitHub sign-in and session state inside the theme provider. Existing pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. ## Other projects diff --git a/hub/docs/development.md b/hub/docs/development.md index b91ecf9f..a49e7532 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -1,6 +1,6 @@ # Hub development -Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. Sign-in and production deployment are not configured. +Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. GitHub sign-in is optional in local development. Production deployment is not configured. ## Devcontainer @@ -20,7 +20,7 @@ cp .env.example .env npm ci --engine-strict ``` -The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. The existing health and frontend routes do not query the database. +The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in; leave both unset to run Hub with sign-in disabled. ## Start the app @@ -36,7 +36,7 @@ In a second terminal, also from `hub/`, start Vite: npm run dev ``` -Open . Vite serves the frontend and forwards `/api` requests to Express at . `GET /api/health` returns JSON; other API routes have not been built. Both servers accept connections only from this machine. Stop each with Ctrl+C. +Open . Vite serves the sign-in surface and forwards `/api` requests to Express at . `GET /api/health` returns JSON. `GET /api/session` reports the browser session and supplies the CSRF value used by the sign-in and sign-out controls. Both servers accept connections only from this machine. Stop each with Ctrl+C. The API reads an optional `PORT` environment variable, defaulting to 3001. If you change it, update the local Vite proxy target to match. The editor forwards ports 3001, 5173, and 6006 from the devcontainer. API startup reads `.env` when present; Compose's environment takes precedence. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 01e1e040..53b6e2b2 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,6 +1,20 @@ # 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 uses GitHub user authorization to prove an external identity and creates provider-neutral browser sessions. Hub installation membership records administrative roles. Later authorization work will let Hub own prototype access independently of repository permissions. + +## GitHub user authentication + +Set `HUB_ORIGIN`, `HUB_GITHUB_AUTH_CLIENT_ID`, and `HUB_GITHUB_AUTH_CLIENT_SECRET` to enable GitHub sign-in. `HUB_ORIGIN` must be an HTTPS origin, except that local development may use HTTP on loopback. The callback is always `${HUB_ORIGIN}/api/auth/github/callback`. + +Hub stores a versioned authentication-provider revision with non-secret configuration and a fingerprint that changes when the client secret changes. The client secret remains in the process environment. Each authorization attempt stores digested state and browser bindings, a PKCE verifier, the provider revision, the current session-revocation generation, its purpose, and a ten-minute expiry. + +The callback consumes an attempt once, checks the same-browser binding and current provider and revocation state, exchanges the code with PKCE, and reads the authenticated GitHub profile. Hub maps GitHub's immutable numeric account ID to `ExternalIdentity.subject`. The user access token exists only while requesting that profile; Hub never stores it. + +Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. + +`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, and no repository permission check gates sign-in. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to require access to at least one installed repository. The GitHub authentication adapter never reads installation credentials or performs repository authorization. + +Sign-out revokes the current database session. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. ## Persisted boundaries @@ -23,12 +37,12 @@ GitHub source access is an optional integration boundary, not the global identit | `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 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. +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. A deployment may use the same GitHub App for user authorization and source integration, but it supplies and rotates the two configurations independently. 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 +## Current 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 adds GitHub sign-in, session restoration, sign-out, fresh verification, global session revocation, and the minimal frontend needed to use them. It does not add invitations or membership administration, prototype authorization, GitHub permission synchronization, artifact ingestion, OIDC, account linking, SCIM, or product APIs. Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/logging.md b/hub/docs/logging.md index 47fca2c8..d6580315 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -40,6 +40,8 @@ router.use(omitRequestLogPayload) This omits `params`, `query`, and `body` while keeping request metadata. +Authentication and session routes install this omission before their handlers run. OAuth codes, state values, CSRF values, cookies, PKCE material, and provider responses therefore never enter automatic request payload records. + ## Structured-field safety 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. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index d637b72a..724dc1a0 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -16,7 +16,7 @@ npm run storybook:build `npm test` runs API and utility tests in Node.js and frontend tests in jsdom, which provides a browser-like environment. Neither requires PostgreSQL. To run a focused API check, use `npm test -- server/create-app/index.test.ts`. HTTP tests listen on temporary loopback ports and close their servers afterward. -The API health check proves only that Express responds. Routes that have not been built must return 404 rather than pretending to work. +The API health check proves only that Express responds. Authentication route tests inject provider clients and identity services; they never require live GitHub credentials or make real GitHub requests. Routes that have not been built must return 404 rather than pretending to work. Installation generates Prisma Client without a database connection. Client tests check reuse across development reloads, production behavior, and errors for missing configuration. @@ -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. +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. Authentication cases also cover PKCE, browser binding, provider revisions, revocation races, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. 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/authorization-attempt.prisma b/hub/schema/authorization-attempt.prisma index f3743ace..251c6fde 100644 --- a/hub/schema/authorization-attempt.prisma +++ b/hub/schema/authorization-attempt.prisma @@ -14,6 +14,7 @@ model AuthorizationAttempt { pkceVerifier String @map("pkce_verifier") nonce String? purpose String + sessionGeneration Int @default(0) @map("session_generation") expiresAt DateTime @map("expires_at") @db.Timestamptz(3) // Timestamp columns diff --git a/hub/schema/identity-store-state.prisma b/hub/schema/identity-store-state.prisma index 06db17be..1b5ca274 100644 --- a/hub/schema/identity-store-state.prisma +++ b/hub/schema/identity-store-state.prisma @@ -2,6 +2,9 @@ model IdentityStoreState { // ID columns id String @id @db.Uuid + // Attribute columns + sessionGeneration Int @default(0) @map("session_generation") + // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) diff --git a/hub/schema/migrations/20261026195151_identity/migration.sql b/hub/schema/migrations/20261026195151_identity/migration.sql index 025776e7..602477f6 100644 --- a/hub/schema/migrations/20261026195151_identity/migration.sql +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -26,6 +26,7 @@ CREATE TABLE "authentication_provider_revisions" ( -- CreateTable CREATE TABLE "identity_store_states" ( "id" UUID NOT NULL, + "session_generation" INTEGER NOT NULL DEFAULT 0, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -111,6 +112,7 @@ CREATE TABLE "authorization_attempts" ( "pkce_verifier" TEXT NOT NULL, "nonce" TEXT, "purpose" TEXT NOT NULL, + "session_generation" INTEGER NOT NULL DEFAULT 0, "expires_at" TIMESTAMPTZ(3) NOT NULL, "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index a3954deb..5ea4c5f6 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -5,15 +5,19 @@ import { handleError } from "../integrations/express/handle-error/index.ts" import { respondNotFound } from "../integrations/express/respond-not-found/index.ts" import { createRequestLogger } from "../integrations/pino/create-request-logger/index.ts" import { routes } from "../routes/index.ts" +import type { AuthenticationRoutes } from "../routes/types.ts" /** Give every endpoint the same request logging and safe error responses. */ -export function createApp({ logger }: { logger?: Logger } = {}) { +export function createApp({ + authentication, + logger, +}: { authentication?: AuthenticationRoutes; logger?: Logger } = {}) { const app = express() app.disable("x-powered-by") app.use(createRequestLogger(logger)) app.use(express.json()) - routes(app) + routes(app, authentication) app.use(respondNotFound) app.use(handleError) diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts new file mode 100644 index 00000000..b938c2e6 --- /dev/null +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -0,0 +1,156 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function completeGitHubAuthorization( + dependencies: AuthenticationDependencies, + code: string, + state: string, + browserToken: string, +): Promise<{ sessionToken?: string; purpose: AuthorizationPurpose }> { + const { admissionPolicy, configuration, github, now, providerRevisionId, store } = dependencies + if (!configuration || !github || !providerRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "GitHub authentication is not configured.", + ) + } + const attempt = await store.lock((records) => + records.consumeAuthorizationAttempt(digestSecret(state)), + ) + if ( + !attempt || + !browserToken || + !secretsMatch(digestSecret(browserToken), attempt.browserDigest) || + attempt.authenticationProviderRevisionId !== providerRevisionId + ) { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid or expired.", + ) + } + let profile: GitHubUserProfile + let admitted: boolean + try { + profile = await github.identify(code, attempt.pkceVerifier) + admitted = attempt.purpose === "verify" ? true : await admissionPolicy.admit(profile) + } catch (error) { + await store.lock((records) => + records.audit({ + event: "authentication.provider_failed", + actorUserId: null, + sessionId: attempt.originatingSessionId, + authenticationProviderRevisionId: providerRevisionId, + }), + ) + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "authentication_provider_failed", + "The authentication provider failed.", + ) + } + if (!admitted) { + await store.lock((records) => + records.audit({ + event: "authentication.admission_denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + }), + ) + throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + } + return store.lock(async (records) => { + const [currentProvider, stateRecord] = await Promise.all([ + records.currentAuthenticationProviderRevision("github"), + records.identityStoreState(), + ]) + if ( + currentProvider?.id !== providerRevisionId || + stateRecord.sessionGeneration !== attempt.sessionGeneration + ) { + throw new AuthenticationError( + 401, + "authorization_superseded", + "The authorization request was superseded.", + ) + } + if (attempt.purpose === "verify") { + const session = attempt.originatingSessionId + ? await records.sessionById(attempt.originatingSessionId) + : null + if ( + !session || + session.externalIdentity.provider !== "github" || + session.externalIdentity.issuer !== configuration.issuer || + session.externalIdentity.subject !== profile.subject + ) { + throw new AuthenticationError(403, "identity_mismatch", "Verify the same GitHub identity.") + } + await records.verifySession(session.id, now()) + await records.audit({ + event: "session.verify", + actorUserId: session.userId, + sessionId: session.id, + authenticationProviderRevisionId: providerRevisionId, + }) + return { purpose: "verify" } + } + let identity = await records.externalIdentity("github", configuration.issuer, profile.subject) + if (!identity) { + const user = await records.createUser({ + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: configuration.issuer, + subject: profile.subject, + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } else { + await records.updateUser(identity.userId, { + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: identity.userId, + provider: "github", + issuer: configuration.issuer, + subject: profile.subject, + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } + const sessionToken = randomBytes(32).toString("base64url") + const session = await records.createSession({ + userId: identity.userId, + externalIdentityId: identity.id, + authenticationProviderRevisionId: providerRevisionId, + tokenDigest: digestSecret(sessionToken), + signedInAt: now(), + freshAuthorizedAt: now(), + }) + await records.audit({ + event: "session.sign_in", + actorUserId: identity.userId, + sessionId: session.id, + authenticationProviderRevisionId: providerRevisionId, + }) + return { sessionToken, purpose: "sign-in" } + }) +} diff --git a/hub/server/domains/authentication/create-admission-policy/index.ts b/hub/server/domains/authentication/create-admission-policy/index.ts new file mode 100644 index 00000000..ad4059a7 --- /dev/null +++ b/hub/server/domains/authentication/create-admission-policy/index.ts @@ -0,0 +1,42 @@ +import type { AdmissionPolicy } from "#server/domains/authentication/create-service/types" +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import { checkRepositoryAdmission } from "#server/integrations/github/check-repository-admission" +import { AuthenticationError } from "#server/utils/authentication-error" + +export function createAdmissionPolicy( + environment: NodeJS.ProcessEnv, + store: IdentityStore, +): AdmissionPolicy { + const selected = environment.HUB_ADMISSION_POLICY ?? "hub-managed" + if (selected === "hub-managed") { + return { name: "hub-managed", admit: () => Promise.resolve(true) } + } + if (selected !== "legacy-repository") { + throw new Error("HUB_ADMISSION_POLICY must be hub-managed or legacy-repository.") + } + const provider = createGitHubIntegrationCredentialProvider(environment) + return { + name: "legacy-repository", + admit: async (profile) => { + try { + const credentials = provider.read(await store.read.githubIntegrationState()) + if (!credentials) { + throw new AuthenticationError( + 503, + "source_integration_unconfigured", + "GitHub source integration is not configured.", + ) + } + return await checkRepositoryAdmission(credentials, profile) + } catch (error) { + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + }, + } +} diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts new file mode 100644 index 00000000..71606f0f --- /dev/null +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -0,0 +1,77 @@ +import { createAdmissionPolicy } from "#server/domains/authentication/create-admission-policy" +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" +import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { prisma } from "#server/integrations/prisma" + +function readOrigin(environment: NodeJS.ProcessEnv): URL { + const origin = new URL(environment.HUB_ORIGIN ?? "http://127.0.0.1:5173") + const secure = origin.protocol === "https:" + if ( + !["https:", "http:"].includes(origin.protocol) || + origin.origin !== origin.href.replace(/\/$/, "") || + origin.username || + origin.password || + (!secure && + (environment.NODE_ENV === "production" || + !["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname))) + ) { + throw new Error( + "Set HUB_ORIGIN to an HTTPS origin, or a loopback HTTP origin outside production.", + ) + } + return origin +} + +function readGitHubAuthentication( + environment: NodeJS.ProcessEnv, + origin: URL, +): GitHubUserAuthConfiguration | null { + const clientId = environment.HUB_GITHUB_AUTH_CLIENT_ID + const clientSecret = environment.HUB_GITHUB_AUTH_CLIENT_SECRET + if (!clientId && !clientSecret) return null + if ( + !clientId || + !clientSecret || + clientId.length > 255 || + clientSecret.length < 20 || + clientSecret.length > 1024 + ) { + throw new Error("Set both HUB_GITHUB_AUTH_CLIENT_ID and HUB_GITHUB_AUTH_CLIENT_SECRET.") + } + return { + clientId, + clientSecret, + callbackUrl: `${origin.origin}/api/auth/github/callback`, + issuer: "https://github.com", + } +} + +export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv) { + const origin = readOrigin(environment) + const configuration = readGitHubAuthentication(environment, origin) + const store = createIdentityStore(prisma) + const providerRevision = configuration + ? await ensureGitHubProviderRevision(store, configuration) + : null + const admissionPolicy = createAdmissionPolicy(environment, store) + const settings: AuthenticationSettings = { + origin: origin.origin, + secureCookies: origin.protocol === "https:", + configured: configuration !== null, + admissionPolicy: admissionPolicy.name, + } + return { + service: createAuthenticationService({ + store, + github: configuration ? createGitHubUserAuthClient(configuration) : null, + configuration, + providerRevisionId: providerRevision?.id ?? null, + admissionPolicy, + }), + settings, + } +} diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts new file mode 100644 index 00000000..510dfce9 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { prisma, store: createIdentityStore(prisma) } +} + +async function createAdministrator(store: IdentityStore) { + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Synthetic administrator", + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "octocat", + displayName: "Synthetic administrator", + avatarUrl: null, + }) + const provider = await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }) + await records.saveMembership({ + userId: user.id, + status: "active", + role: "administrator", + source: "manual", + }) + return { identity, provider, user } + }) +} + +async function createSession( + store: IdentityStore, + graph: Awaited>, + token: string, +) { + const authorizedAt = new Date() + return store.lock((records) => + records.createSession({ + userId: graph.user.id, + externalIdentityId: graph.identity.id, + authenticationProviderRevisionId: graph.provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }), + ) +} + +describe("createAuthenticationService() database behavior", () => { + it("rolls back generation, session deletion, and audit together when revocation fails", async () => { + const { store } = await database() + const graph = await createAdministrator(store) + const session = await createSession(store, graph, "rollback-token") + + await expect( + store.lock(async (records) => { + await records.advanceSessionGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + actorUserId: graph.user.id, + sessionId: session.id, + authenticationProviderRevisionId: graph.provider.id, + }) + throw new Error("revocation failed") + }), + ).rejects.toThrow("revocation failed") + + expect(await store.read.identityStoreState()).toEqual({ sessionGeneration: 0 }) + expect(await store.read.session(digestSecret("rollback-token"))).toMatchObject({ + id: session.id, + }) + expect(await store.read.audits()).toEqual([]) + }) + + it("serializes concurrent revoke-all requests so generation advances exactly once", async () => { + const { store } = await database() + const graph = await createAdministrator(store) + await createSession(store, graph, "administrator-token") + await createSession(store, graph, "other-token") + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(true) }, + }) + + const results = await Promise.allSettled([ + service.revokeAll("administrator-token"), + service.revokeAll("administrator-token"), + ]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + expect(await store.read.identityStoreState()).toEqual({ sessionGeneration: 1 }) + expect(await store.read.session(digestSecret("administrator-token"))).toBeNull() + expect(await store.read.session(digestSecret("other-token"))).toBeNull() + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "session.revoke_all", + actorUserId: graph.user.id, + }), + ]) + }) +}) diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts new file mode 100644 index 00000000..a5512919 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi } from "vitest" +import type { Mocked } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +const now = new Date("2026-10-27T18:00:00.000Z") +const configuration = { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com" as const, +} +const providerRevision = { + id: "provider-revision", + authenticationProviderId: "provider", + revision: 1, + issuer: configuration.issuer, + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, +} + +type MockIdentityRecords = Mocked + +function records(overrides: Partial = {}) { + const fixture = { + currentAuthenticationProviderRevision: vi.fn().mockResolvedValue(providerRevision), + identityStoreState: vi.fn().mockResolvedValue({ sessionGeneration: 3 }), + createAuthorizationAttempt: vi.fn().mockResolvedValue(undefined), + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(null), + externalIdentity: vi.fn().mockResolvedValue(null), + createUser: vi.fn(), + updateUser: vi.fn(), + saveExternalIdentity: vi.fn(), + createSession: vi.fn(), + verifySession: vi.fn(), + deleteSession: vi.fn(), + deleteSessions: vi.fn(), + advanceSessionGeneration: vi.fn(), + session: vi.fn().mockResolvedValue(null), + sessionById: vi.fn().mockResolvedValue(null), + membership: vi.fn().mockResolvedValue(null), + audit: vi.fn(), + } + return Object.assign(fixture, overrides) +} + +type TestIdentityRecords = ReturnType + +function store(identityRecords: TestIdentityRecords): IdentityStore { + const typedRecords = identityRecords as unknown as IdentityRecords + return { + read: typedRecords, + lock: (operation) => operation(typedRecords), + } +} + +function dependencies( + identityRecords: TestIdentityRecords, + overrides: Partial< + Pick + > = {}, +) { + const github: Mocked = { + authorizationUrl: vi.fn( + (state, verifier) => `https://github.example/authorize?state=${state}&verifier=${verifier}`, + ), + identify: vi.fn(), + } + return { + store: store(identityRecords), + github, + configuration, + providerRevisionId: providerRevision.id, + admissionPolicy: { + name: "hub-managed" as const, + admit: vi.fn().mockResolvedValue(true), + }, + now: () => now, + ...overrides, + } +} + +function attempt(overrides: Record = {}) { + return { + id: "attempt", + authenticationProviderRevisionId: providerRevision.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in" as const, + sessionGeneration: 3, + expiresAt: new Date(now.getTime() + 60_000), + ...overrides, + } +} + +describe("createAuthenticationService()", () => { + it("starts a short-lived browser-bound authorization with state and an S256-ready verifier", async () => { + const identityRecords = records() + const options = dependencies(identityRecords) + const service = createAuthenticationService(options) + + const result = await service.start("sign-in") + + const authorizationUrl = new URL(result.authorizationUrl) + const state = authorizationUrl.searchParams.get("state") + const verifier = authorizationUrl.searchParams.get("verifier") + expect(state).toMatch(/^[\w-]{43}$/) + expect(result.browserToken).toMatch(/^[\w-]{43}$/) + expect(verifier).toMatch(/^[\w-]{43}$/) + expect(identityRecords.createAuthorizationAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + stateDigest: digestSecret(state!), + browserDigest: digestSecret(result.browserToken), + pkceVerifier: verifier, + purpose: "sign-in", + sessionGeneration: 3, + expiresAt: new Date(now.getTime() + 10 * 60_000), + }), + ) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalledWith( + expect.objectContaining({ stateDigest: state, browserDigest: result.browserToken }), + ) + }) + + it("consumes an attempt before rejecting the wrong browser so neither replay can exchange a code", async () => { + let available = attempt() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn(() => { + const consumed = available + available = null as never + return Promise.resolve(consumed) + }), + }) + const options = dependencies(identityRecords) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "wrong-browser")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + expect(options.github.identify.mock.calls).toHaveLength(0) + }) + + it("fails closed when the store reports an expired or unknown authorization attempt", async () => { + const identityRecords = records() + const options = dependencies(identityRecords) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + expect(options.github.identify.mock.calls).toHaveLength(0) + }) + + it("updates the existing immutable GitHub identity and creates a session without repository data", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "old-login", + displayName: "Old name", + avatarUrl: null, + user: { id: "user", displayName: "Old name", avatarUrl: null }, + } + const updatedIdentity = { + ...existingIdentity, + username: "new-login", + displayName: "New name", + avatarUrl: "https://avatars.example/42", + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + updateUser: vi.fn().mockResolvedValue(updatedIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(updatedIdentity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const options = dependencies(identityRecords) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "new-login", + displayName: "New name", + avatarUrl: "https://avatars.example/42", + }) + const service = createAuthenticationService(options) + + const result = await service.complete("one-use-code", "state", "browser-token") + + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.saveExternalIdentity).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user", subject: "42", username: "new-login" }), + ) + expect(identityRecords.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + externalIdentityId: "identity", + tokenDigest: digestSecret(result.sessionToken!), + }), + ) + expect(JSON.stringify(vi.mocked(identityRecords.createSession).mock.calls)).not.toContain( + "one-use-code", + ) + }) + + it("lets a compatibility admission policy deny sign-in before creating identity records", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const admissionPolicy = { + name: "legacy-repository" as const, + admit: vi.fn().mockResolvedValue(false), + } + const options = dependencies(identityRecords, { admissionPolicy }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(admissionPolicy.admit).toHaveBeenCalledWith(expect.objectContaining({ subject: "42" })) + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it("refreshes authorization only when GitHub returns the session's same external identity", async () => { + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "digest", + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "User", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }, + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi + .fn() + .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + sessionById: vi.fn().mockResolvedValue(session), + }) + const options = dependencies(identityRecords) + options.github.identify.mockResolvedValue({ + subject: "99", + username: "different-user", + displayName: null, + avatarUrl: null, + }) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "identity_mismatch", + }) + expect(identityRecords.verifySession).not.toHaveBeenCalled() + + options.github.identify.mockResolvedValue({ + subject: "42", + username: "renamed-user", + displayName: null, + avatarUrl: null, + }) + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + await expect(service.complete("code", "state", "browser-token")).resolves.toEqual({ + purpose: "verify", + }) + expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) + }) + + it("signs out only the current session and requires fresh administrator access to revoke all", async () => { + const current = { + id: "current-session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: digestSecret("session-token"), + signedInAt: now, + freshAuthorizedAt: new Date(now.getTime() - 11 * 60_000), + user: { id: "user", displayName: "Administrator", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }, + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + session: vi.fn().mockResolvedValue(current), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "administrator", + source: "manual", + }), + }) + const service = createAuthenticationService(dependencies(identityRecords)) + + await service.signOut("session-token") + expect(identityRecords.deleteSession).toHaveBeenCalledWith(current.id) + expect(identityRecords.deleteSessions).not.toHaveBeenCalled() + + await expect(service.revokeAll("session-token")).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + expect(identityRecords.advanceSessionGeneration).not.toHaveBeenCalled() + + current.freshAuthorizedAt = now + await service.revokeAll("session-token") + expect(identityRecords.advanceSessionGeneration).toHaveBeenCalledOnce() + expect(identityRecords.deleteSessions).toHaveBeenCalledOnce() + }) +}) diff --git a/hub/server/domains/authentication/create-service/index.ts b/hub/server/domains/authentication/create-service/index.ts new file mode 100644 index 00000000..ea67bf62 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.ts @@ -0,0 +1,25 @@ +import { completeGitHubAuthorization } from "#server/domains/authentication/complete-github-authorization" +import type { + AuthenticationDependencies, + AuthenticationService, +} from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { revokeSessions } from "#server/domains/authentication/revoke-sessions" +import { signOut } from "#server/domains/authentication/sign-out" +import { startGitHubAuthorization } from "#server/domains/authentication/start-github-authorization" + +export function createAuthenticationService( + options: Omit & { now?: () => Date }, +): AuthenticationService { + const dependencies: AuthenticationDependencies = { + ...options, + now: options.now ?? (() => new Date()), + } + return { + start: startGitHubAuthorization.bind(null, dependencies), + complete: completeGitHubAuthorization.bind(null, dependencies), + current: getCurrentSession.bind(null, dependencies), + signOut: signOut.bind(null, dependencies), + revokeAll: revokeSessions.bind(null, dependencies), + } +} diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts new file mode 100644 index 00000000..da14ff5b --- /dev/null +++ b/hub/server/domains/authentication/create-service/types.ts @@ -0,0 +1,49 @@ +import type { IdentityStore, SessionContext } from "#server/domains/identity/create-store/types" +import type { + GitHubUserAuthClient, + GitHubUserAuthConfiguration, + GitHubUserProfile, +} from "#server/integrations/github/create-user-auth-client/types" +import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" + +export type AuthorizationPurpose = "sign-in" | "verify" + +export interface AdmissionPolicy { + name: "hub-managed" | "legacy-repository" + admit(profile: GitHubUserProfile): Promise +} + +export interface AuthenticationSettings { + origin: string + secureCookies: boolean + configured: boolean + admissionPolicy: AdmissionPolicy["name"] +} + +export interface AuthenticationContext extends SessionContext { + membership: InstallationMembershipRecord | null +} + +export interface AuthenticationService { + start( + purpose: AuthorizationPurpose, + sessionToken?: string, + ): Promise<{ authorizationUrl: string; browserToken: string }> + complete( + code: string, + state: string, + browserToken: string, + ): Promise<{ sessionToken?: string; purpose: AuthorizationPurpose }> + current(sessionToken: string): Promise + signOut(sessionToken: string): Promise + revokeAll(sessionToken: string): Promise +} + +export interface AuthenticationDependencies { + store: IdentityStore + github: GitHubUserAuthClient | null + configuration: GitHubUserAuthConfiguration | null + providerRevisionId: string | null + admissionPolicy: AdmissionPolicy + now: () => Date +} diff --git a/hub/server/domains/authentication/ensure-provider-revision/index.ts b/hub/server/domains/authentication/ensure-provider-revision/index.ts new file mode 100644 index 00000000..af7cf76a --- /dev/null +++ b/hub/server/domains/authentication/ensure-provider-revision/index.ts @@ -0,0 +1,50 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +function storedConfiguration(configuration: GitHubUserAuthConfiguration): Record { + return { + clientId: configuration.clientId, + callbackUrl: configuration.callbackUrl, + fingerprint: digestSecret( + `${configuration.clientId}\0${configuration.clientSecret}\0${configuration.callbackUrl}`, + ), + } +} + +function matches(value: unknown, expected: Record): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + return ( + "clientId" in value && + value.clientId === expected.clientId && + "callbackUrl" in value && + value.callbackUrl === expected.callbackUrl && + "fingerprint" in value && + value.fingerprint === expected.fingerprint + ) +} + +export function ensureGitHubProviderRevision( + store: IdentityStore, + configuration: GitHubUserAuthConfiguration, +) { + return store.lock(async (records) => { + const current = await records.currentAuthenticationProviderRevision("github") + const stored = storedConfiguration(configuration) + if ( + current?.authenticationProvider.type === "github" && + current.issuer === configuration.issuer && + matches(current.configuration, stored) + ) { + return current + } + return records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: (current?.revision ?? 0) + 1, + issuer: configuration.issuer, + configuration: stored, + }) + }) +} diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts new file mode 100644 index 00000000..933104a6 --- /dev/null +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -0,0 +1,17 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" + +export async function getCurrentSession( + dependencies: AuthenticationDependencies, + sessionToken: string, + records: IdentityRecords = dependencies.store.read, +) { + const session = sessionToken ? await records.session(digestSecret(sessionToken)) : null + if (!session) { + throw new AuthenticationError(401, "sign_in_required", "Sign in to continue.") + } + const membership = await records.membership(session.userId) + return { ...session, membership } +} diff --git a/hub/server/domains/authentication/revoke-sessions/index.ts b/hub/server/domains/authentication/revoke-sessions/index.ts new file mode 100644 index 00000000..4363e2eb --- /dev/null +++ b/hub/server/domains/authentication/revoke-sessions/index.ts @@ -0,0 +1,36 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" + +const freshAuthorizationMilliseconds = 10 * 60_000 + +export async function revokeSessions( + dependencies: AuthenticationDependencies, + sessionToken: string, +): Promise { + const { now, store } = dependencies + await store.lock(async (records) => { + const current = await getCurrentSession(dependencies, sessionToken, records) + if (current.membership?.status !== "active" || current.membership.role !== "administrator") { + throw new AuthenticationError(403, "administrator_required", "Administrator access required.") + } + if ( + !current.freshAuthorizedAt || + now().getTime() - current.freshAuthorizedAt.getTime() > freshAuthorizationMilliseconds + ) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Fresh authorization required.", + ) + } + await records.advanceSessionGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + actorUserId: current.userId, + sessionId: current.id, + authenticationProviderRevisionId: current.authenticationProviderRevisionId, + }) + }) +} diff --git a/hub/server/domains/authentication/sign-out/index.ts b/hub/server/domains/authentication/sign-out/index.ts new file mode 100644 index 00000000..dc4cbc4e --- /dev/null +++ b/hub/server/domains/authentication/sign-out/index.ts @@ -0,0 +1,18 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" + +export async function signOut( + dependencies: AuthenticationDependencies, + sessionToken: string, +): Promise { + await dependencies.store.lock(async (records) => { + const current = await getCurrentSession(dependencies, sessionToken, records) + await records.deleteSession(current.id) + await records.audit({ + event: "session.sign_out", + actorUserId: current.userId, + sessionId: current.id, + authenticationProviderRevisionId: current.authenticationProviderRevisionId, + }) + }) +} diff --git a/hub/server/domains/authentication/start-github-authorization/index.ts b/hub/server/domains/authentication/start-github-authorization/index.ts new file mode 100644 index 00000000..afde2351 --- /dev/null +++ b/hub/server/domains/authentication/start-github-authorization/index.ts @@ -0,0 +1,57 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" + +export async function startGitHubAuthorization( + dependencies: AuthenticationDependencies, + purpose: AuthorizationPurpose, + sessionToken?: string, +) { + const { configuration, github, now, providerRevisionId, store } = dependencies + if (!configuration || !github || !providerRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "GitHub authentication is not configured.", + ) + } + const state = randomBytes(32).toString("base64url") + const browserToken = randomBytes(32).toString("base64url") + const pkceVerifier = randomBytes(32).toString("base64url") + await store.lock(async (records) => { + const provider = await records.currentAuthenticationProviderRevision("github") + if (provider?.id !== providerRevisionId) { + throw new AuthenticationError( + 409, + "provider_changed", + "GitHub authentication changed. Start again.", + ) + } + const session = + purpose === "verify" + ? await getCurrentSession(dependencies, sessionToken ?? "", records) + : null + const stateRecord = await records.identityStoreState() + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: providerRevisionId, + originatingSessionId: session?.id ?? null, + stateDigest: digestSecret(state), + browserDigest: digestSecret(browserToken), + pkceVerifier, + nonce: null, + purpose, + sessionGeneration: stateRecord.sessionGeneration, + expiresAt: new Date(now().getTime() + 10 * 60_000), + }) + }) + return { + authorizationUrl: github.authorizationUrl(state, pkceVerifier), + browserToken, + } +} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 82411d9c..451ae87d 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -1,4 +1,6 @@ import { createAuthenticationProviderRevision } from "#server/repositories/authentication-providers/create-provider-revision" +import { getCurrentAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-current-revision" +import { getAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-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" @@ -6,6 +8,8 @@ import { saveExternalIdentity } from "#server/repositories/external-identities/s 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 { advanceSessionGeneration } from "#server/repositories/identity-store-state/advance-session-generation" +import { getIdentityStoreState } from "#server/repositories/identity-store-state/get-state" 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" @@ -19,22 +23,31 @@ import { invalidateFreshness } from "#server/repositories/sessions/invalidate-fr import { verifySession } from "#server/repositories/sessions/verify-session" import { createUser } from "#server/repositories/users/create-user" import { getUser } from "#server/repositories/users/get-user" +import { updateUser } from "#server/repositories/users/update-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 { + identityStoreState: getIdentityStoreState.bind(null, database), + advanceSessionGeneration: advanceSessionGeneration.bind(null, database), githubIntegrationState: getGitHubIntegrationState.bind(null, database), saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), advanceGitHubIntegrationGeneration: advanceGitHubIntegrationGeneration.bind(null, database), user: getUser.bind(null, database), createUser: createUser.bind(null, database), + updateUser: updateUser.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), + currentAuthenticationProviderRevision: getCurrentAuthenticationProviderRevision.bind( + null, + database, + ), + authenticationProviderRevision: getAuthenticationProviderRevision.bind(null, database), session: getSession.bind(null, database), sessionById: getSessionById.bind(null, database), createSession: createSession.bind(null, database), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 378667d2..7ffb9d19 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -12,6 +12,7 @@ import type { import type { AuditInput } from "#server/repositories/security-audits/types" import type { HubSession } from "#server/repositories/sessions/types" import type { HubUser, UserInput } from "#server/repositories/users/types" +import type { IdentityStoreState } from "#server/repositories/identity-store-state/types" export interface AuthenticationProviderRevisionRecord { id: string @@ -34,11 +35,14 @@ export interface SessionContext extends HubSession { } export interface IdentityRecords { + identityStoreState(): Promise + advanceSessionGeneration(): Promise githubIntegrationState(): Promise saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise advanceGitHubIntegrationGeneration(): Promise user(id: string): Promise createUser(input: UserInput): Promise + updateUser(id: string, input: UserInput): Promise externalIdentity( provider: string, issuer: string, @@ -52,6 +56,10 @@ export interface IdentityRecords { createAuthenticationProviderRevision( input: AuthenticationProviderInput, ): Promise + currentAuthenticationProviderRevision( + key: string, + ): Promise + authenticationProviderRevision(id: string): Promise session(tokenDigest: string): Promise sessionById(id: string): Promise createSession(input: Omit): Promise @@ -59,7 +67,11 @@ export interface IdentityRecords { invalidateFreshness(): Promise deleteSession(id: string): Promise deleteSessions(): Promise - createAuthorizationAttempt(input: Omit): Promise + createAuthorizationAttempt( + input: Omit & { + sessionGeneration?: number + }, + ): Promise consumeAuthorizationAttempt(stateDigest: string): Promise audit(input: AuditInput): Promise audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> diff --git a/hub/server/index.ts b/hub/server/index.ts index 64bafe13..af5b0cbc 100644 --- a/hub/server/index.ts +++ b/hub/server/index.ts @@ -1,6 +1,7 @@ import "dotenv/config" import { createApp } from "./create-app/index.ts" +import { createAuthenticationRuntime } from "./domains/authentication/create-runtime/index.ts" import { log } from "./integrations/pino/log/index.ts" const port = Number(process.env.PORT ?? 3001) @@ -8,6 +9,8 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("PORT must be an integer between 1 and 65535.") } -createApp().listen(port, "127.0.0.1", () => { +const authentication = await createAuthenticationRuntime(process.env) + +createApp({ authentication }).listen(port, "127.0.0.1", () => { log("info", "Hub API listening", { host: "127.0.0.1", port }) }) diff --git a/hub/server/integrations/express/create-rate-limit/index.ts b/hub/server/integrations/express/create-rate-limit/index.ts new file mode 100644 index 00000000..5170ffd2 --- /dev/null +++ b/hub/server/integrations/express/create-rate-limit/index.ts @@ -0,0 +1,37 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function createRateLimit({ + limit, + windowMilliseconds, + maxEntries = 10_000, +}: { + limit: number + windowMilliseconds: number + maxEntries?: number +}) { + const entries = new Map() + + return function rateLimit(request: Request, _response: Response, next: NextFunction): void { + const now = Date.now() + const key = request.ip || request.socket.remoteAddress || "unknown" + for (const [entryKey, value] of entries) { + if (value.resetAt <= now) entries.delete(entryKey) + } + if (!entries.has(key) && entries.size >= maxEntries) { + const oldest = entries.keys().next().value + if (oldest) entries.delete(oldest) + } + const current = entries.get(key) + const entry = + !current || current.resetAt <= now ? { count: 0, resetAt: now + windowMilliseconds } : current + entry.count++ + entries.set(key, entry) + if (entry.count > limit) { + next(new AuthenticationError(429, "rate_limited", "Too many authentication requests.")) + return + } + next() + } +} diff --git a/hub/server/integrations/express/prepare-authentication-request/index.ts b/hub/server/integrations/express/prepare-authentication-request/index.ts new file mode 100644 index 00000000..d5f45fd7 --- /dev/null +++ b/hub/server/integrations/express/prepare-authentication-request/index.ts @@ -0,0 +1,21 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function prepareAuthenticationRequest( + expectedOrigin: string, + request: Request, + response: Response, + next: NextFunction, +): void { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + if ( + !["GET", "HEAD", "OPTIONS"].includes(request.method) && + request.get("origin") !== expectedOrigin + ) { + next(new AuthenticationError(403, "origin_failed", "The request origin did not match.")) + return + } + next() +} diff --git a/hub/server/integrations/express/respond-authentication-error/index.ts b/hub/server/integrations/express/respond-authentication-error/index.ts new file mode 100644 index 00000000..778502c8 --- /dev/null +++ b/hub/server/integrations/express/respond-authentication-error/index.ts @@ -0,0 +1,26 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function respondAuthenticationError( + error: unknown, + _request: Request, + response: Response, + next: NextFunction, +): void { + if (!(error instanceof AuthenticationError)) { + next(error) + return + } + const message = + error.status === 401 + ? "Authentication required" + : error.status === 403 + ? "Request not allowed" + : error.status === 429 + ? "Too many requests" + : error.status >= 500 + ? "Authentication unavailable" + : "Authentication failed" + response.status(error.status).json({ error: message }) +} diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts new file mode 100644 index 00000000..45291b89 --- /dev/null +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -0,0 +1,10 @@ +import { z } from "zod" + +export const installedRepositoriesSchema = z.object({ + repositories: z.array(z.object({ full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/) })), +}) + +export const collaboratorPermissionSchema = z.object({ + user: z.object({ id: z.number().int().positive() }), + permission: z.enum(["admin", "maintain", "write", "triage", "read", "none"]), +}) diff --git a/hub/server/integrations/github/check-repository-admission/index.ts b/hub/server/integrations/github/check-repository-admission/index.ts new file mode 100644 index 00000000..014c4ea1 --- /dev/null +++ b/hub/server/integrations/github/check-repository-admission/index.ts @@ -0,0 +1,57 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { createInstallationToken } from "#server/integrations/github/create-installation-token" +import { + collaboratorPermissionSchema, + installedRepositoriesSchema, +} from "#server/integrations/github/check-repository-admission/constants" +import { GitHubApiError } from "#server/integrations/github/github-api-error" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { AuthenticationError } from "#server/utils/authentication-error" + +async function hasPermission( + token: string, + profile: GitHubUserProfile, + repository: string, +): Promise { + let response: unknown + try { + response = await requestGitHubApi( + `/repos/${repository}/collaborators/${encodeURIComponent(profile.username)}/permission`, + token, + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return false + throw error + } + const permission = collaboratorPermissionSchema.parse(response) + if (String(permission.user.id) !== profile.subject) { + throw new AuthenticationError( + 403, + "identity_mismatch", + "The repository permission identity did not match.", + ) + } + return permission.permission !== "none" +} + +export async function checkRepositoryAdmission( + credentials: GitHubCredentials, + profile: GitHubUserProfile, +): Promise { + const token = await createInstallationToken(credentials) + for (let page = 1; page <= 100; page++) { + const response = installedRepositoriesSchema.parse( + await requestGitHubApi(`/installation/repositories?per_page=100&page=${page}`, token), + ) + for (const repository of response.repositories) { + if (await hasPermission(token, profile, repository.full_name)) return true + } + if (response.repositories.length < 100) return false + } + throw new AuthenticationError( + 503, + "repository_limit", + "Repository admission exceeded the supported page limit.", + ) +} diff --git a/hub/server/integrations/github/create-app-token/index.ts b/hub/server/integrations/github/create-app-token/index.ts new file mode 100644 index 00000000..8423e0c8 --- /dev/null +++ b/hub/server/integrations/github/create-app-token/index.ts @@ -0,0 +1,16 @@ +import { sign } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function createAppToken(credentials: GitHubCredentials): string { + const now = Math.floor(Date.now() / 1000) + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url") + const payload = Buffer.from( + JSON.stringify({ iat: now - 60, exp: now + 540, iss: credentials.appId }), + ).toString("base64url") + const unsigned = `${header}.${payload}` + const signature = sign("RSA-SHA256", Buffer.from(unsigned), credentials.privateKey).toString( + "base64url", + ) + return `${unsigned}.${signature}` +} diff --git a/hub/server/integrations/github/create-installation-token/constants.ts b/hub/server/integrations/github/create-installation-token/constants.ts new file mode 100644 index 00000000..1c01e5df --- /dev/null +++ b/hub/server/integrations/github/create-installation-token/constants.ts @@ -0,0 +1,3 @@ +import { z } from "zod" + +export const installationTokenSchema = z.object({ token: z.string().min(1) }) diff --git a/hub/server/integrations/github/create-installation-token/index.ts b/hub/server/integrations/github/create-installation-token/index.ts new file mode 100644 index 00000000..f7c36b28 --- /dev/null +++ b/hub/server/integrations/github/create-installation-token/index.ts @@ -0,0 +1,13 @@ +import { createAppToken } from "#server/integrations/github/create-app-token" +import { installationTokenSchema } from "#server/integrations/github/create-installation-token/constants" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export async function createInstallationToken(credentials: GitHubCredentials): Promise { + const response = await requestGitHubApi( + `/app/installations/${credentials.installationId}/access_tokens`, + createAppToken(credentials), + "POST", + ) + return installationTokenSchema.parse(response).token +} diff --git a/hub/server/integrations/github/create-user-auth-client/index.test.ts b/hub/server/integrations/github/create-user-auth-client/index.test.ts new file mode 100644 index 00000000..85fb1e34 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/index.test.ts @@ -0,0 +1,153 @@ +import { createHash } from "node:crypto" +import { describe, expect, it, vi } from "vitest" + +import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" + +const configuration = { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com" as const, +} + +describe("createGitHubUserAuthClient()", () => { + it("builds a state-bound S256 authorization URL without repository scopes", () => { + const client = createGitHubUserAuthClient(configuration) + const verifier = "pkce-verifier-fixture" + + const url = new URL(client.authorizationUrl("state-fixture", verifier)) + + expect(url.origin + url.pathname).toBe("https://github.com/login/oauth/authorize") + expect(Object.fromEntries(url.searchParams)).toEqual({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + state: "state-fixture", + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + }) + expect(url.searchParams.has("scope")).toBe(false) + }) + + it("keeps the user access token transient while returning the immutable GitHub subject", async () => { + const accessToken = "github-user-access-token-fixture" + const requests: Array<{ input: string; init?: RequestInit }> = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const requestUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push({ input: requestUrl, init }) + if (requests.length === 1) { + return Promise.resolve( + new Response(JSON.stringify({ access_token: accessToken, token_type: "bearer" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + } + return Promise.resolve( + new Response( + JSON.stringify({ + id: 42, + login: "octocat", + name: "The Octocat", + avatar_url: "https://avatars.githubusercontent.com/u/42", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + }), + ) + const client = createGitHubUserAuthClient(configuration) + + const profile = await client.identify("one-use-code", "pkce-verifier") + + expect(profile).toEqual({ + subject: "42", + username: "octocat", + displayName: "The Octocat", + avatarUrl: "https://avatars.githubusercontent.com/u/42", + }) + const tokenRequest = requests[0] + const userRequest = requests[1] + expect(tokenRequest).toBeDefined() + expect(userRequest).toBeDefined() + expect(tokenRequest!).toMatchObject({ + input: "https://github.com/login/oauth/access_token", + init: { + method: "POST", + redirect: "error", + }, + }) + const tokenBody = tokenRequest!.init?.body + if (typeof tokenBody !== "string") throw new Error("Expected a JSON token request body") + expect(JSON.parse(tokenBody) as unknown).toMatchObject({ + code: "one-use-code", + code_verifier: "pkce-verifier", + redirect_uri: configuration.callbackUrl, + }) + expect(userRequest!).toMatchObject({ + input: "https://api.github.com/user", + init: { redirect: "error" }, + }) + expect(new Headers(userRequest!.init?.headers).get("authorization")).toBe( + `Bearer ${accessToken}`, + ) + expect(JSON.stringify(profile)).not.toContain(accessToken) + }) + + it("rejects malformed token and profile responses instead of constructing a partial identity", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: 7 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ) + const client = createGitHubUserAuthClient(configuration) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 401, + code: "github_denied", + }) + + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "token", token_type: "bearer" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: "mutable-login", login: "octocat" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_invalid_response", + }) + }) + + it("maps network and non-success responses to generic availability failures", async () => { + const client = createGitHubUserAuthClient(configuration) + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("token leaked in transport error"))) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_unavailable", + message: "GitHub is unavailable.", + }) + + vi.mocked(fetch).mockResolvedValue(new Response("private upstream failure", { status: 502 })) + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_unavailable", + message: "GitHub is unavailable.", + }) + }) +}) diff --git a/hub/server/integrations/github/create-user-auth-client/index.ts b/hub/server/integrations/github/create-user-auth-client/index.ts new file mode 100644 index 00000000..fe323255 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/index.ts @@ -0,0 +1,17 @@ +import { createUserAuthorizationUrl } from "#server/integrations/github/create-user-authorization-url" +import type { + GitHubUserAuthClient, + GitHubUserAuthConfiguration, +} from "#server/integrations/github/create-user-auth-client/types" +import { exchangeUserAuthorization } from "#server/integrations/github/exchange-user-authorization" +import { requestGitHubUser } from "#server/integrations/github/request-github-user" + +export function createGitHubUserAuthClient( + configuration: GitHubUserAuthConfiguration, +): GitHubUserAuthClient { + return { + authorizationUrl: createUserAuthorizationUrl.bind(null, configuration), + identify: async (code, pkceVerifier) => + requestGitHubUser(await exchangeUserAuthorization(configuration, code, pkceVerifier)), + } +} diff --git a/hub/server/integrations/github/create-user-auth-client/types.ts b/hub/server/integrations/github/create-user-auth-client/types.ts new file mode 100644 index 00000000..f47e0447 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/types.ts @@ -0,0 +1,18 @@ +export interface GitHubUserAuthConfiguration { + clientId: string + clientSecret: string + callbackUrl: string + issuer: "https://github.com" +} + +export interface GitHubUserProfile { + subject: string + username: string + displayName: string | null + avatarUrl: string | null +} + +export interface GitHubUserAuthClient { + authorizationUrl(state: string, pkceVerifier: string): string + identify(code: string, pkceVerifier: string): Promise +} diff --git a/hub/server/integrations/github/create-user-authorization-url/index.ts b/hub/server/integrations/github/create-user-authorization-url/index.ts new file mode 100644 index 00000000..80010614 --- /dev/null +++ b/hub/server/integrations/github/create-user-authorization-url/index.ts @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto" + +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" + +export function createUserAuthorizationUrl( + configuration: GitHubUserAuthConfiguration, + state: string, + pkceVerifier: string, +): string { + const url = new URL("https://github.com/login/oauth/authorize") + url.search = new URLSearchParams({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + state, + code_challenge: createHash("sha256").update(pkceVerifier).digest("base64url"), + code_challenge_method: "S256", + }).toString() + return url.href +} diff --git a/hub/server/integrations/github/exchange-user-authorization/constants.ts b/hub/server/integrations/github/exchange-user-authorization/constants.ts new file mode 100644 index 00000000..e40d8a1f --- /dev/null +++ b/hub/server/integrations/github/exchange-user-authorization/constants.ts @@ -0,0 +1,6 @@ +import { z } from "zod" + +export const accessTokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.literal("bearer"), +}) diff --git a/hub/server/integrations/github/exchange-user-authorization/index.ts b/hub/server/integrations/github/exchange-user-authorization/index.ts new file mode 100644 index 00000000..0271af76 --- /dev/null +++ b/hub/server/integrations/github/exchange-user-authorization/index.ts @@ -0,0 +1,42 @@ +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { accessTokenSchema } from "#server/integrations/github/exchange-user-authorization/constants" +import { AuthenticationError } from "#server/utils/authentication-error" + +export async function exchangeUserAuthorization( + configuration: GitHubUserAuthConfiguration, + code: string, + pkceVerifier: string, +): Promise { + let response: Response + try { + response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: configuration.clientId, + client_secret: configuration.clientSecret, + code, + code_verifier: pkceVerifier, + redirect_uri: configuration.callbackUrl, + }), + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + if (!response.ok) { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + let body: unknown + try { + body = await response.json() + } catch { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + const parsed = accessTokenSchema.safeParse(body) + if (!parsed.success) { + throw new AuthenticationError(401, "github_denied", "GitHub denied authorization.") + } + return parsed.data.access_token +} diff --git a/hub/server/integrations/github/github-api-error/index.ts b/hub/server/integrations/github/github-api-error/index.ts new file mode 100644 index 00000000..cf23cbc9 --- /dev/null +++ b/hub/server/integrations/github/github-api-error/index.ts @@ -0,0 +1,5 @@ +export class GitHubApiError extends Error { + constructor(readonly status: number) { + super("GitHub API request failed.") + } +} diff --git a/hub/server/integrations/github/request-github-api/index.ts b/hub/server/integrations/github/request-github-api/index.ts new file mode 100644 index 00000000..8d8eece3 --- /dev/null +++ b/hub/server/integrations/github/request-github-api/index.ts @@ -0,0 +1,25 @@ +import { GitHubApiError } from "#server/integrations/github/github-api-error" + +export async function requestGitHubApi( + path: string, + token: string, + method = "GET", +): Promise { + let response: Response + try { + response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new GitHubApiError(0) + } + if (!response.ok) throw new GitHubApiError(response.status) + return response.json() +} diff --git a/hub/server/integrations/github/request-github-user/constants.ts b/hub/server/integrations/github/request-github-user/constants.ts new file mode 100644 index 00000000..7b5e7b7b --- /dev/null +++ b/hub/server/integrations/github/request-github-user/constants.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const githubUserSchema = z.object({ + id: z.number().int().positive(), + login: z.string().min(1).max(255), + name: z.string().max(255).nullable(), + avatar_url: z.url().nullable(), +}) diff --git a/hub/server/integrations/github/request-github-user/index.ts b/hub/server/integrations/github/request-github-user/index.ts new file mode 100644 index 00000000..104afe4a --- /dev/null +++ b/hub/server/integrations/github/request-github-user/index.ts @@ -0,0 +1,39 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { githubUserSchema } from "#server/integrations/github/request-github-user/constants" +import { AuthenticationError } from "#server/utils/authentication-error" + +export async function requestGitHubUser(accessToken: string): Promise { + let response: Response + try { + response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + if (!response.ok) { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + let body: unknown + try { + body = await response.json() + } catch { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + const parsed = githubUserSchema.safeParse(body) + if (!parsed.success) { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + return { + subject: String(parsed.data.id), + username: parsed.data.login, + displayName: parsed.data.name, + avatarUrl: parsed.data.avatar_url, + } +} diff --git a/hub/server/repositories/authentication-providers/get-current-revision/index.ts b/hub/server/repositories/authentication-providers/get-current-revision/index.ts new file mode 100644 index 00000000..6d202814 --- /dev/null +++ b/hub/server/repositories/authentication-providers/get-current-revision/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getCurrentAuthenticationProviderRevision( + database: Prisma.TransactionClient, + key: string, +) { + return database.authenticationProviderRevision.findFirst({ + where: { authenticationProvider: { key, enabled: true } }, + orderBy: { revision: "desc" }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authentication-providers/get-revision/index.ts b/hub/server/repositories/authentication-providers/get-revision/index.ts new file mode 100644 index 00000000..6abb080a --- /dev/null +++ b/hub/server/repositories/authentication-providers/get-revision/index.ts @@ -0,0 +1,8 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getAuthenticationProviderRevision(database: Prisma.TransactionClient, id: string) { + return database.authenticationProviderRevision.findUnique({ + where: { id }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts index 669ddec7..638dab02 100644 --- a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -22,6 +22,7 @@ export async function consumeAuthorizationAttempt( pkce_verifier AS "pkceVerifier", nonce, purpose, + session_generation AS "sessionGeneration", expires_at AS "expiresAt", expires_at > clock_timestamp() AS valid ` @@ -43,6 +44,7 @@ export async function consumeAuthorizationAttempt( pkceVerifier: attempt.pkceVerifier, nonce: attempt.nonce, purpose: attempt.purpose, + sessionGeneration: attempt.sessionGeneration, expiresAt: attempt.expiresAt, } } diff --git a/hub/server/repositories/authorization-attempts/create-attempt/index.ts b/hub/server/repositories/authorization-attempts/create-attempt/index.ts index 9660c8cf..cab6fc09 100644 --- a/hub/server/repositories/authorization-attempts/create-attempt/index.ts +++ b/hub/server/repositories/authorization-attempts/create-attempt/index.ts @@ -4,7 +4,9 @@ import type { Prisma } from "../../../generated/prisma/client.ts" export async function createAuthorizationAttempt( database: Prisma.TransactionClient, - data: Omit, + data: Omit & { + sessionGeneration?: number + }, ) { 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 index 3687dc29..54e18768 100644 --- a/hub/server/repositories/authorization-attempts/types.ts +++ b/hub/server/repositories/authorization-attempts/types.ts @@ -7,5 +7,6 @@ export interface AuthorizationAttempt { pkceVerifier: string nonce: string | null purpose: "sign-in" | "verify" | "link-identity" + sessionGeneration: number expiresAt: Date } diff --git a/hub/server/repositories/identity-store-state/advance-session-generation/index.ts b/hub/server/repositories/identity-store-state/advance-session-generation/index.ts new file mode 100644 index 00000000..c4be0fba --- /dev/null +++ b/hub/server/repositories/identity-store-state/advance-session-generation/index.ts @@ -0,0 +1,10 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function advanceSessionGeneration(database: Prisma.TransactionClient) { + await database.identityStoreState.update({ + where: { id: staticIdentityStoreRecordId }, + data: { sessionGeneration: { increment: 1 } }, + }) +} diff --git a/hub/server/repositories/identity-store-state/get-state/index.ts b/hub/server/repositories/identity-store-state/get-state/index.ts new file mode 100644 index 00000000..2633c9ca --- /dev/null +++ b/hub/server/repositories/identity-store-state/get-state/index.ts @@ -0,0 +1,10 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getIdentityStoreState(database: Prisma.TransactionClient) { + return database.identityStoreState.findUniqueOrThrow({ + where: { id: staticIdentityStoreRecordId }, + select: { sessionGeneration: true }, + }) +} diff --git a/hub/server/repositories/identity-store-state/types.ts b/hub/server/repositories/identity-store-state/types.ts new file mode 100644 index 00000000..fb896ced --- /dev/null +++ b/hub/server/repositories/identity-store-state/types.ts @@ -0,0 +1,3 @@ +export interface IdentityStoreState { + sessionGeneration: number +} diff --git a/hub/server/repositories/users/update-user/index.ts b/hub/server/repositories/users/update-user/index.ts new file mode 100644 index 00000000..81b5e698 --- /dev/null +++ b/hub/server/repositories/users/update-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 updateUser(database: Prisma.TransactionClient, id: string, input: UserInput) { + return database.user.update({ where: { id }, data: input }) +} diff --git a/hub/server/routes/api/auth/github/callback/get/index.ts b/hub/server/routes/api/auth/github/callback/get/index.ts new file mode 100644 index 00000000..6e65d07c --- /dev/null +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -0,0 +1,49 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { AuthenticationError } from "#server/utils/authentication-error" +import { readCookie } from "#server/utils/read-cookie" + +function validCallbackValue(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 1_000 +} + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + const failureUrl = `${authentication.settings.origin}/?authentication=failed` + response.clearCookie(cookies.authorization, cookies.privateOptions) + const { code, state } = request.query + if ( + request.query.error !== undefined || + !validCallbackValue(code) || + !validCallbackValue(state) || + !/^[\w-]{43}$/.test(state) + ) { + response.redirect(303, failureUrl) + return + } + try { + const result = await authentication.service.complete( + code, + state, + readCookie(request.headers.cookie, cookies.authorization), + ) + if (result.sessionToken) { + response.cookie(cookies.session, result.sessionToken, cookies.privateOptions) + } + response.cookie(cookies.csrf, randomBytes(32).toString("base64url"), cookies.csrfOptions) + response.redirect(303, `${authentication.settings.origin}/?authentication=complete`) + } catch (error) { + if (error instanceof AuthenticationError) { + response.redirect(303, failureUrl) + return + } + throw error + } +} diff --git a/hub/server/routes/api/auth/github/post/index.ts b/hub/server/routes/api/auth/github/post/index.ts new file mode 100644 index 00000000..dc98fd36 --- /dev/null +++ b/hub/server/routes/api/auth/github/post/index.ts @@ -0,0 +1,24 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const attempt = await authentication.service.start("sign-in") + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} diff --git a/hub/server/routes/api/session/delete/index.ts b/hub/server/routes/api/session/delete/index.ts new file mode 100644 index 00000000..bdc2c946 --- /dev/null +++ b/hub/server/routes/api/session/delete/index.ts @@ -0,0 +1,26 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +async function deleteSession( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.service.signOut(readCookie(request.headers.cookie, cookies.session)) + response.clearCookie(cookies.session, cookies.privateOptions) + response.clearCookie(cookies.authorization, cookies.privateOptions) + response.cookie(cookies.csrf, randomBytes(32).toString("base64url"), cookies.csrfOptions) + response.status(204).end() +} + +export { deleteSession as delete } diff --git a/hub/server/routes/api/session/get/index.ts b/hub/server/routes/api/session/get/index.ts new file mode 100644 index 00000000..3e6cde1b --- /dev/null +++ b/hub/server/routes/api/session/get/index.ts @@ -0,0 +1,50 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { AuthenticationError } from "#server/utils/authentication-error" +import { readCookie } from "#server/utils/read-cookie" + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + let csrfToken = readCookie(request.headers.cookie, cookies.csrf) + if (!csrfToken) { + csrfToken = randomBytes(32).toString("base64url") + response.cookie(cookies.csrf, csrfToken, cookies.csrfOptions) + } + const sessionToken = readCookie(request.headers.cookie, cookies.session) + try { + const current = await authentication.service.current(sessionToken) + response.json({ + authenticated: true, + configured: authentication.settings.configured, + admissionPolicy: authentication.settings.admissionPolicy, + csrfToken, + user: { + id: current.user.id, + displayName: current.user.displayName, + avatarUrl: current.user.avatarUrl, + provider: current.externalIdentity.provider, + username: current.externalIdentity.username, + }, + session: { + id: current.id, + signedInAt: current.signedInAt, + freshAuthorizedAt: current.freshAuthorizedAt, + }, + }) + } catch (error) { + if (!(error instanceof AuthenticationError) || error.status !== 401) throw error + response.json({ + authenticated: false, + configured: authentication.settings.configured, + admissionPolicy: authentication.settings.admissionPolicy, + csrfToken, + }) + } +} diff --git a/hub/server/routes/api/session/revoke-all/post/index.ts b/hub/server/routes/api/session/revoke-all/post/index.ts new file mode 100644 index 00000000..6736ae19 --- /dev/null +++ b/hub/server/routes/api/session/revoke-all/post/index.ts @@ -0,0 +1,21 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.service.revokeAll(readCookie(request.headers.cookie, cookies.session)) + response.clearCookie(cookies.session, cookies.privateOptions) + response.status(204).end() +} diff --git a/hub/server/routes/api/session/verify/post/index.ts b/hub/server/routes/api/session/verify/post/index.ts new file mode 100644 index 00000000..fac5b672 --- /dev/null +++ b/hub/server/routes/api/session/verify/post/index.ts @@ -0,0 +1,27 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const attempt = await authentication.service.start( + "verify", + readCookie(request.headers.cookie, cookies.session), + ) + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts new file mode 100644 index 00000000..41e4c5a5 --- /dev/null +++ b/hub/server/routes/authentication.test.ts @@ -0,0 +1,272 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" +import { createLogger } from "#server/integrations/pino/create-logger" +import { AuthenticationError } from "#server/utils/authentication-error" + +import { createApp } from "../create-app/index.ts" + +function authentication( + overrides: Partial> = {}, + secureCookies = true, +) { + const service = Object.assign( + { + start: vi.fn().mockResolvedValue({ + authorizationUrl: "https://github.com/login/oauth/authorize?state=redacted", + browserToken: "browser-token", + }), + complete: vi.fn().mockResolvedValue({ + purpose: "sign-in", + sessionToken: "session-token", + }), + current: vi + .fn() + .mockRejectedValue( + new AuthenticationError(401, "sign_in_required", "Sign in to continue."), + ), + signOut: vi.fn().mockResolvedValue(undefined), + revokeAll: vi.fn().mockResolvedValue(undefined), + }, + overrides, + ) + return { + settings: { + origin: "https://hub.example", + secureCookies, + configured: true, + admissionPolicy: "hub-managed" as const, + }, + service, + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +async function serve(runtime: AuthenticationRoutes, logger = createLogger()) { + const server = createServer(createApp({ authentication: runtime, logger })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const browserToken = "b".repeat(43) +const csrfHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "x-csrf-token": csrfToken, +} + +describe("authentication routes", () => { + it("sets host-bound secure cookies and omits repository scope during GitHub initiation", async () => { + const runtime = authentication({ + start: vi.fn().mockResolvedValue({ + authorizationUrl: + "https://github.com/login/oauth/authorize?client_id=client&state=state&code_challenge=challenge&code_challenge_method=S256", + browserToken, + }), + }) + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: csrfHeaders, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + authorizationUrl: + "https://github.com/login/oauth/authorize?client_id=client&state=state&code_challenge=challenge&code_challenge_method=S256", + }) + expect(response.headers.get("set-cookie")).toMatch( + new RegExp( + `__Host-replica-authorization=${browserToken}; Max-Age=600; Path=/; Expires=[^;]+; HttpOnly; Secure; SameSite=Lax`, + "i", + ), + ) + expect(runtime.service.start).toHaveBeenCalledWith("sign-in") + }) + + it("rejects state-changing requests unless both the origin and CSRF token match", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const wrongOrigin = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: { ...csrfHeaders, origin: "https://attacker.example" }, + }) + expect(wrongOrigin.status).toBe(403) + expect(await wrongOrigin.json()).toEqual({ error: "Request not allowed" }) + + const wrongCsrf = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: { ...csrfHeaders, "x-csrf-token": "wrong-token" }, + }) + expect(wrongCsrf.status).toBe(403) + expect(await wrongCsrf.json()).toEqual({ error: "Request not allowed" }) + expect(runtime.service.signOut).not.toHaveBeenCalled() + }) + + it("rotates browser state on sign-out and delegates revoke-all only with a protected request", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const signOut = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: csrfHeaders, + }) + expect(signOut.status).toBe(204) + expect(runtime.service.signOut).toHaveBeenCalledWith(sessionToken) + const signOutCookies = signOut.headers.get("set-cookie") ?? "" + expect(signOutCookies).toContain("__Host-replica-session=") + expect(signOutCookies).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT") + expect(signOutCookies).toMatch(/__Host-replica-csrf=[\w-]{43}/) + + const revokeAll = await fetch(`${baseUrl}/api/session/revoke-all`, { + method: "POST", + headers: csrfHeaders, + }) + expect(revokeAll.status).toBe(204) + expect(runtime.service.revokeAll).toHaveBeenCalledWith(sessionToken) + }) + + it("redirects callback failures generically and never logs OAuth secrets", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication({ + complete: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 401, + "invalid_authorization", + "private callback failure containing a token", + ), + ), + }) + const baseUrl = await serve(runtime, logger) + const state = "s".repeat(43) + + const response = await fetch( + `${baseUrl}/api/auth/github/callback?code=private-code&state=${state}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.status).toBe(303) + expect(response.headers.get("location")).toBe("https://hub.example/?authentication=failed") + expect(response.headers.get("set-cookie")).toMatch( + /__Host-replica-authorization=; Path=\/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax/i, + ) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify(records)).not.toMatch( + /private-code|bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb|callback failure|token/, + ) + }) + + it("allows the initiation limit and rejects the next request without calling the service", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const responses = [] + for (let request = 0; request < 21; request++) { + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: csrfHeaders, + }), + ) + } + + expect(responses.slice(0, 20).every(({ status }) => status === 200)).toBe(true) + const limitedResponse = responses[20] + expect(limitedResponse).toBeDefined() + expect(limitedResponse!.status).toBe(429) + expect(await limitedResponse!.json()).toEqual({ error: "Too many requests" }) + expect(runtime.service.start).toHaveBeenCalledTimes(20) + }) + + it("returns session state without exposing the session token and creates a readable CSRF cookie", async () => { + const runtime = authentication({ + current: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: "revision", + tokenDigest: "stored-digest", + signedInAt: new Date("2026-10-27T18:00:00.000Z"), + freshAuthorizedAt: null, + user: { id: "user", displayName: "The Octocat", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "octocat", + displayName: "The Octocat", + avatarUrl: null, + }, + authenticationProviderRevision: { + id: "revision", + authenticationProviderId: "provider", + revision: 1, + issuer: "https://github.com", + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, + }, + membership: null, + }), + }) + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/session`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + const body: unknown = await response.json() + + expect(response.status).toBe(200) + expect(body).toMatchObject({ + authenticated: true, + configured: true, + admissionPolicy: "hub-managed", + user: { id: "user", provider: "github", username: "octocat" }, + session: { id: "session", freshAuthorizedAt: null }, + }) + expect(isRecord(body) ? body.csrfToken : undefined).toMatch(/^[\w-]{43}$/) + expect(response.headers.get("set-cookie")).toMatch( + /__Host-replica-csrf=[\w-]{43}; Path=\/; Secure; SameSite=Lax/i, + ) + expect(response.headers.get("set-cookie")).not.toContain("HttpOnly") + expect(JSON.stringify(body)).not.toContain(sessionToken) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }) +}) diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index e5216064..06a3d8ac 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -1,8 +1,49 @@ import type { Express } from "express" +import { createRateLimit } from "#server/integrations/express/create-rate-limit" +import { prepareAuthenticationRequest } from "#server/integrations/express/prepare-authentication-request" +import { respondAuthenticationError } from "#server/integrations/express/respond-authentication-error" +import { omitRequestLogPayload } from "#server/integrations/pino/omit-request-log-payload" +import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" +import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" import { get as handleApiHealthGet } from "./api/health/get/index.ts" +import { delete as handleApiSessionDelete } from "#server/routes/api/session/delete" +import { get as handleApiSessionGet } from "#server/routes/api/session/get" +import { post as handleApiSessionRevokeAllPost } from "#server/routes/api/session/revoke-all/post" +import { post as handleApiSessionVerifyPost } from "#server/routes/api/session/verify/post" +import type { AuthenticationRoutes } from "#server/routes/types" /** Keep endpoint registration separate from the app's shared middleware. */ -export function routes(app: Express): void { +export function routes(app: Express, authentication?: AuthenticationRoutes): void { app.get("/api/health", handleApiHealthGet) + if (!authentication) return + + app.use( + ["/api/auth", "/api/session"], + prepareAuthenticationRequest.bind(null, authentication.settings.origin), + omitRequestLogPayload, + ) + const initiationLimit = createRateLimit({ + limit: 20, + windowMilliseconds: 5 * 60_000, + }) + const callbackLimit = createRateLimit({ + limit: 60, + windowMilliseconds: 5 * 60_000, + }) + app.post("/api/auth/github", initiationLimit, handleApiAuthGithubPost.bind(null, authentication)) + app.get( + "/api/auth/github/callback", + callbackLimit, + handleApiAuthGithubCallbackGet.bind(null, authentication), + ) + app.get("/api/session", handleApiSessionGet.bind(null, authentication)) + app.delete("/api/session", handleApiSessionDelete.bind(null, authentication)) + app.post( + "/api/session/verify", + initiationLimit, + handleApiSessionVerifyPost.bind(null, authentication), + ) + app.post("/api/session/revoke-all", handleApiSessionRevokeAllPost.bind(null, authentication)) + app.use(respondAuthenticationError) } diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts new file mode 100644 index 00000000..eadccf7d --- /dev/null +++ b/hub/server/routes/types.ts @@ -0,0 +1,9 @@ +import type { + AuthenticationService, + AuthenticationSettings, +} from "#server/domains/authentication/create-service/types" + +export interface AuthenticationRoutes { + service: AuthenticationService + settings: AuthenticationSettings +} diff --git a/hub/server/utils/authentication-cookies/index.ts b/hub/server/utils/authentication-cookies/index.ts new file mode 100644 index 00000000..9faf37e6 --- /dev/null +++ b/hub/server/utils/authentication-cookies/index.ts @@ -0,0 +1,20 @@ +export function authenticationCookies(secure: boolean) { + const prefix = secure ? "__Host-" : "" + return { + session: `${prefix}replica-session`, + authorization: `${prefix}replica-authorization`, + csrf: `${prefix}replica-csrf`, + privateOptions: { + httpOnly: true, + secure, + sameSite: "lax", + path: "/", + } as const, + csrfOptions: { + httpOnly: false, + secure, + sameSite: "lax", + path: "/", + } as const, + } +} diff --git a/hub/server/utils/authentication-error/index.ts b/hub/server/utils/authentication-error/index.ts new file mode 100644 index 00000000..456811d5 --- /dev/null +++ b/hub/server/utils/authentication-error/index.ts @@ -0,0 +1,9 @@ +export class AuthenticationError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message) + } +} diff --git a/hub/server/utils/digest-secret/index.ts b/hub/server/utils/digest-secret/index.ts new file mode 100644 index 00000000..19742d88 --- /dev/null +++ b/hub/server/utils/digest-secret/index.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto" + +export function digestSecret(value: string): string { + return createHash("sha256").update(value).digest("base64url") +} diff --git a/hub/server/utils/read-cookie/index.ts b/hub/server/utils/read-cookie/index.ts new file mode 100644 index 00000000..56974d8d --- /dev/null +++ b/hub/server/utils/read-cookie/index.ts @@ -0,0 +1,8 @@ +export function readCookie(header: string | undefined, name: string): string { + const values = (header ?? "") + .split(";") + .map((part) => part.trim()) + .filter((part) => part.startsWith(`${name}=`)) + const value = values.length === 1 ? (values[0]?.slice(name.length + 1) ?? "") : "" + return /^[\w-]{43}$/.test(value) ? value : "" +} diff --git a/hub/server/utils/require-csrf-token/index.ts b/hub/server/utils/require-csrf-token/index.ts new file mode 100644 index 00000000..5ace370e --- /dev/null +++ b/hub/server/utils/require-csrf-token/index.ts @@ -0,0 +1,8 @@ +import { AuthenticationError } from "#server/utils/authentication-error" +import { secretsMatch } from "#server/utils/secrets-match" + +export function requireCsrfToken(cookieToken: string, headerToken: string): void { + if (!cookieToken || !secretsMatch(cookieToken, headerToken)) { + throw new AuthenticationError(403, "csrf_failed", "The CSRF token did not match.") + } +} diff --git a/hub/server/utils/secrets-match/index.ts b/hub/server/utils/secrets-match/index.ts new file mode 100644 index 00000000..24ad8b46 --- /dev/null +++ b/hub/server/utils/secrets-match/index.ts @@ -0,0 +1,7 @@ +import { timingSafeEqual } from "node:crypto" + +export function secretsMatch(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} From a3248542ea4168f907ff33bbd8bff1ccd72230cb Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 13:07:34 -0700 Subject: [PATCH 06/56] fix(hub): harden auth request boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 3 + hub/docs/development.md | 2 + hub/docs/logging.md | 4 +- hub/server/create-app/index.ts | 1 + .../authentication/create-runtime/index.ts | 2 + .../authentication/create-service/types.ts | 1 + .../read-trusted-proxy-hops/index.test.ts | 16 +++++ .../express/read-trusted-proxy-hops/index.ts | 11 ++++ hub/server/routes/authentication.test.ts | 64 ++++++++++++++++++- hub/server/routes/index.ts | 2 +- 10 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts create mode 100644 hub/server/integrations/express/read-trusted-proxy-hops/index.ts diff --git a/hub/.env.example b/hub/.env.example index 59b34228..3b33a5dc 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -7,6 +7,9 @@ LOG_LEVEL=info # Public browser origin. Production requires HTTPS; local HTTP must use loopback. HUB_ORIGIN=http://127.0.0.1:5173 +# Keep 0 unless the API is reachable only through this exact number of trusted proxies. +HUB_TRUSTED_PROXY_HOPS=0 + # Local development only. The devcontainer supplies its own database hostname. DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica diff --git a/hub/docs/development.md b/hub/docs/development.md index a49e7532..43ffbe47 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -40,6 +40,8 @@ Open . Vite serves the sign-in surface and forwards `/api The API reads an optional `PORT` environment variable, defaulting to 3001. If you change it, update the local Vite proxy target to match. The editor forwards ports 3001, 5173, and 6006 from the devcontainer. API startup reads `.env` when present; Compose's environment takes precedence. +Hub ignores forwarded client-address headers by default and rate-limits the direct connection address. Keep `HUB_TRUSTED_PROXY_HOPS=0` for local development and any deployment where clients can reach the API directly. A deployment whose API is reachable only through a fixed proxy chain may set the exact number of trusted hops. Every trusted proxy must replace or append the client address correctly, and the network must prevent clients from bypassing the first trusted proxy. Never enable proxy trust only to accept arbitrary `Forwarded` or `X-Forwarded-For` headers. + ## Database lifecycle PostgreSQL stores data in a named Compose volume. Stopping or rebuilding the devcontainer preserves it. For a local Node.js setup, stop the database with `docker compose -f .devcontainer/compose.yaml down`; this also preserves data. Do not add `--volumes` unless you intend to permanently delete the local database. diff --git a/hub/docs/logging.md b/hub/docs/logging.md index d6580315..a76a1461 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,7 +30,7 @@ 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. -Authentication routers should install `omitRequestLogPayload` before their handlers so callback payloads are excluded by default: +Authentication routers must install `omitRequestLogPayload` before their origin checks, rate limits, and handlers so every auth-specific early response excludes the payload: ```ts import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" @@ -40,7 +40,7 @@ router.use(omitRequestLogPayload) This omits `params`, `query`, and `body` while keeping request metadata. -Authentication and session routes install this omission before their handlers run. OAuth codes, state values, CSRF values, cookies, PKCE material, and provider responses therefore never enter automatic request payload records. +Authentication and session routes install this omission before any auth-specific middleware that can reject the request. OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, and unrelated private fields therefore never enter automatic request payload records. ## Structured-field safety diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 5ea4c5f6..72883a86 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -14,6 +14,7 @@ export function createApp({ }: { authentication?: AuthenticationRoutes; logger?: Logger } = {}) { const app = express() app.disable("x-powered-by") + app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) app.use(express.json()) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 71606f0f..63be7add 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -5,6 +5,7 @@ import { ensureGitHubProviderRevision } from "#server/domains/authentication/ens import { createIdentityStore } from "#server/domains/identity/create-store" import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" import { prisma } from "#server/integrations/prisma" function readOrigin(environment: NodeJS.ProcessEnv): URL { @@ -63,6 +64,7 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv secureCookies: origin.protocol === "https:", configured: configuration !== null, admissionPolicy: admissionPolicy.name, + trustedProxyHops: readTrustedProxyHops(environment), } return { service: createAuthenticationService({ diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts index da14ff5b..19700b9c 100644 --- a/hub/server/domains/authentication/create-service/types.ts +++ b/hub/server/domains/authentication/create-service/types.ts @@ -18,6 +18,7 @@ export interface AuthenticationSettings { secureCookies: boolean configured: boolean admissionPolicy: AdmissionPolicy["name"] + trustedProxyHops: number } export interface AuthenticationContext extends SessionContext { diff --git a/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts b/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts new file mode 100644 index 00000000..394d99be --- /dev/null +++ b/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" + +import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" + +describe("readTrustedProxyHops()", () => { + it("ignores forwarded client addresses unless an exact proxy count is configured", () => { + expect(readTrustedProxyHops({})).toBe(0) + expect(readTrustedProxyHops({ HUB_TRUSTED_PROXY_HOPS: "2" })).toBe(2) + }) + + it.each(["-1", "1.5", "11", "edge", " 1"])("rejects the invalid proxy count %s", (value) => { + expect(() => readTrustedProxyHops({ HUB_TRUSTED_PROXY_HOPS: value })).toThrow( + "HUB_TRUSTED_PROXY_HOPS", + ) + }) +}) diff --git a/hub/server/integrations/express/read-trusted-proxy-hops/index.ts b/hub/server/integrations/express/read-trusted-proxy-hops/index.ts new file mode 100644 index 00000000..67b8bdaf --- /dev/null +++ b/hub/server/integrations/express/read-trusted-proxy-hops/index.ts @@ -0,0 +1,11 @@ +export function readTrustedProxyHops(environment: NodeJS.ProcessEnv): number { + const value = environment.HUB_TRUSTED_PROXY_HOPS ?? "0" + if (!/^(?:0|[1-9]\d*)$/.test(value)) { + throw new Error("HUB_TRUSTED_PROXY_HOPS must be an integer from 0 through 10.") + } + const hops = Number(value) + if (hops > 10) { + throw new Error("HUB_TRUSTED_PROXY_HOPS must be an integer from 0 through 10.") + } + return hops +} diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 41e4c5a5..69b2fe9c 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -14,6 +14,7 @@ import { createApp } from "../create-app/index.ts" function authentication( overrides: Partial> = {}, secureCookies = true, + trustedProxyHops = 0, ) { const service = Object.assign( { @@ -41,6 +42,7 @@ function authentication( secureCookies, configured: true, admissionPolicy: "hub-managed" as const, + trustedProxyHops, }, service, } @@ -123,6 +125,33 @@ describe("authentication routes", () => { expect(runtime.service.signOut).not.toHaveBeenCalled() }) + it("omits rejected auth payloads before origin middleware can finish the request", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication() + const baseUrl = await serve(runtime, logger) + + const response = await fetch(`${baseUrl}/api/auth/github?note=query-private-value`, { + method: "POST", + headers: { + ...csrfHeaders, + origin: "https://attacker.example", + "content-type": "application/json", + }, + body: JSON.stringify({ note: "body-private-value" }), + }) + + expect(response.status).toBe(403) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("query") + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toMatch(/query-private-value|body-private-value/) + }) + it("rotates browser state on sign-out and delegates revoke-all only with a protected request", async () => { const runtime = authentication() const baseUrl = await serve(runtime) @@ -195,7 +224,10 @@ describe("authentication routes", () => { responses.push( await fetch(`${baseUrl}/api/auth/github`, { method: "POST", - headers: csrfHeaders, + headers: { + ...csrfHeaders, + "x-forwarded-for": `198.51.100.${request + 1}`, + }, }), ) } @@ -208,6 +240,36 @@ describe("authentication routes", () => { expect(runtime.service.start).toHaveBeenCalledTimes(20) }) + it("separates rate limits by forwarded client only behind the configured proxy boundary", async () => { + const runtime = authentication({}, true, 1) + const baseUrl = await serve(runtime) + + const responses = [] + for (let request = 0; request < 20; request++) { + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: { + ...csrfHeaders, + "x-forwarded-for": "198.51.100.10", + }, + }), + ) + } + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: { + ...csrfHeaders, + "x-forwarded-for": "203.0.113.20", + }, + }), + ) + + expect(responses.every(({ status }) => status === 200)).toBe(true) + expect(runtime.service.start).toHaveBeenCalledTimes(21) + }) + it("returns session state without exposing the session token and creates a readable CSRF cookie", async () => { const runtime = authentication({ current: vi.fn().mockResolvedValue({ diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 06a3d8ac..9a55b16f 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -20,8 +20,8 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi app.use( ["/api/auth", "/api/session"], - prepareAuthenticationRequest.bind(null, authentication.settings.origin), omitRequestLogPayload, + prepareAuthenticationRequest.bind(null, authentication.settings.origin), ) const initiationLimit = createRateLimit({ limit: 20, From e6112602326bd0b7576f42194dcc98ebece3b48a Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 13:13:06 -0700 Subject: [PATCH 07/56] fix(hub): omit auth payloads before parsing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/logging.md | 6 ++-- hub/server/create-app/index.ts | 2 ++ hub/server/routes/authentication.test.ts | 36 ++++++++++++++++++++++++ hub/server/routes/index.ts | 2 -- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/hub/docs/logging.md b/hub/docs/logging.md index a76a1461..e66f3d3c 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,17 +30,17 @@ 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. -Authentication routers must install `omitRequestLogPayload` before their origin checks, rate limits, and handlers so every auth-specific early response excludes the payload: +`createApp()` installs `omitRequestLogPayload` for authentication and session paths after request logging but before JSON parsing. This ordering covers parser failures as well as later origin checks, rate limits, and handlers: ```ts import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" -router.use(omitRequestLogPayload) +app.use(["/api/auth", "/api/session"], omitRequestLogPayload) ``` This omits `params`, `query`, and `body` while keeping request metadata. -Authentication and session routes install this omission before any auth-specific middleware that can reject the request. OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, and unrelated private fields therefore never enter automatic request payload records. +OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, and unrelated private fields therefore never enter automatic request payload records, including when JSON is malformed or too large. ## Structured-field safety diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 72883a86..ef54e6b7 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -4,6 +4,7 @@ import type { Logger } from "pino" import { handleError } from "../integrations/express/handle-error/index.ts" import { respondNotFound } from "../integrations/express/respond-not-found/index.ts" import { createRequestLogger } from "../integrations/pino/create-request-logger/index.ts" +import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" import { routes } from "../routes/index.ts" import type { AuthenticationRoutes } from "../routes/types.ts" @@ -16,6 +17,7 @@ export function createApp({ app.disable("x-powered-by") app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) + app.use(["/api/auth", "/api/session"], omitRequestLogPayload) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 69b2fe9c..4308650e 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -152,6 +152,42 @@ describe("authentication routes", () => { expect(JSON.stringify(records)).not.toMatch(/query-private-value|body-private-value/) }) + it.each([ + { + name: "malformed", + body: '{"note":"body-private-value"', + status: 400, + }, + { + name: "oversized", + body: JSON.stringify({ note: "body-private-value".repeat(100_000) }), + status: 413, + }, + ])("omits auth payloads when the JSON parser rejects a $name body", async ({ body, status }) => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const baseUrl = await serve(authentication(), logger) + + const response = await fetch(`${baseUrl}/api/auth/github?note=query-private-value`, { + method: "POST", + headers: { + ...csrfHeaders, + "content-type": "application/json", + }, + body, + }) + + expect(response.status).toBe(status) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("query") + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toMatch(/query-private-value|body-private-value/) + }) + it("rotates browser state on sign-out and delegates revoke-all only with a protected request", async () => { const runtime = authentication() const baseUrl = await serve(runtime) diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 9a55b16f..d1af83ee 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -3,7 +3,6 @@ import type { Express } from "express" import { createRateLimit } from "#server/integrations/express/create-rate-limit" import { prepareAuthenticationRequest } from "#server/integrations/express/prepare-authentication-request" import { respondAuthenticationError } from "#server/integrations/express/respond-authentication-error" -import { omitRequestLogPayload } from "#server/integrations/pino/omit-request-log-payload" import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" import { get as handleApiHealthGet } from "./api/health/get/index.ts" @@ -20,7 +19,6 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi app.use( ["/api/auth", "/api/session"], - omitRequestLogPayload, prepareAuthenticationRequest.bind(null, authentication.settings.origin), ) const initiationLimit = createRateLimit({ From b4b959760969ac8af16df4a6e24ec20dd4b0205c Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 18:13:32 -0700 Subject: [PATCH 08/56] fix(hub): align session revocation freshness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/identity.md | 2 +- .../authentication/create-service/index.test.ts | 12 +++++++++--- .../authentication/fresh-authorization/constants.ts | 1 + .../domains/authentication/revoke-sessions/index.ts | 11 +++++++---- 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 hub/server/domains/authentication/fresh-authorization/constants.ts diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 53b6e2b2..72fd0947 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -14,7 +14,7 @@ Hub sessions use a random opaque cookie and store only its digest. Authenticatio `HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, and no repository permission check gates sign-in. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to require access to at least one installed repository. The GitHub authentication adapter never reads installation credentials or performs repository authorization. -Sign-out revokes the current database session. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. +Sign-out revokes the current database session. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with verification less than five minutes old may revoke all sessions; revocation also supersedes callbacks that started earlier. ## Persisted boundaries diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index a5512919..55555c8b 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -313,7 +313,7 @@ describe("createAuthenticationService()", () => { expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) }) - it("signs out only the current session and requires fresh administrator access to revoke all", async () => { + it("signs out only the current session and requires administrator proof strictly younger than five minutes to revoke all", async () => { const current = { id: "current-session", userId: "user", @@ -321,7 +321,7 @@ describe("createAuthenticationService()", () => { authenticationProviderRevisionId: providerRevision.id, tokenDigest: digestSecret("session-token"), signedInAt: now, - freshAuthorizedAt: new Date(now.getTime() - 11 * 60_000), + freshAuthorizedAt: new Date(now.getTime() - 5 * 60_000), user: { id: "user", displayName: "Administrator", avatarUrl: null }, externalIdentity: { id: "identity", @@ -357,7 +357,13 @@ describe("createAuthenticationService()", () => { }) expect(identityRecords.advanceSessionGeneration).not.toHaveBeenCalled() - current.freshAuthorizedAt = now + current.freshAuthorizedAt = new Date(now.getTime() + 1) + await expect(service.revokeAll("session-token")).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + + current.freshAuthorizedAt = new Date(now.getTime() - (5 * 60_000 - 1)) await service.revokeAll("session-token") expect(identityRecords.advanceSessionGeneration).toHaveBeenCalledOnce() expect(identityRecords.deleteSessions).toHaveBeenCalledOnce() diff --git a/hub/server/domains/authentication/fresh-authorization/constants.ts b/hub/server/domains/authentication/fresh-authorization/constants.ts new file mode 100644 index 00000000..471e4c61 --- /dev/null +++ b/hub/server/domains/authentication/fresh-authorization/constants.ts @@ -0,0 +1 @@ +export const administratorFreshAuthorizationLifetimeMilliseconds = 5 * 60_000 diff --git a/hub/server/domains/authentication/revoke-sessions/index.ts b/hub/server/domains/authentication/revoke-sessions/index.ts index 4363e2eb..1488055e 100644 --- a/hub/server/domains/authentication/revoke-sessions/index.ts +++ b/hub/server/domains/authentication/revoke-sessions/index.ts @@ -1,9 +1,8 @@ import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import { administratorFreshAuthorizationLifetimeMilliseconds } from "#server/domains/authentication/fresh-authorization/constants" import { getCurrentSession } from "#server/domains/authentication/get-current-session" import { AuthenticationError } from "#server/utils/authentication-error" -const freshAuthorizationMilliseconds = 10 * 60_000 - export async function revokeSessions( dependencies: AuthenticationDependencies, sessionToken: string, @@ -14,9 +13,13 @@ export async function revokeSessions( if (current.membership?.status !== "active" || current.membership.role !== "administrator") { throw new AuthenticationError(403, "administrator_required", "Administrator access required.") } + const freshAuthorizationAge = current.freshAuthorizedAt + ? now().getTime() - current.freshAuthorizedAt.getTime() + : null if ( - !current.freshAuthorizedAt || - now().getTime() - current.freshAuthorizedAt.getTime() > freshAuthorizationMilliseconds + freshAuthorizationAge === null || + freshAuthorizationAge < 0 || + freshAuthorizationAge >= administratorFreshAuthorizationLifetimeMilliseconds ) { throw new AuthenticationError( 403, From d8e9c6312b5217d286ef53874865af737d914bae Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 13:45:56 -0700 Subject: [PATCH 09/56] feat(hub): add installation membership administration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 5 + hub/README.md | 4 +- .../application/authentication/index.tsx | 112 +++++++- .../index.test.tsx | 53 ++++ .../membership-administration-page/index.tsx | 204 ++++++++++++++ .../stories.tsx | 72 +++++ .../membership-administration-page/styles.css | 45 +++ .../membership-administration-page/types.ts | 48 ++++ .../pages/sign-in-page/index.test.tsx | 6 + .../javascript/pages/sign-in-page/index.tsx | 52 +++- .../javascript/pages/sign-in-page/stories.tsx | 2 + .../javascript/pages/sign-in-page/styles.css | 24 ++ .../javascript/pages/sign-in-page/types.ts | 2 + hub/docs/README.md | 1 + hub/docs/architecture.md | 8 +- hub/docs/development.md | 2 +- hub/docs/identity.md | 21 +- hub/docs/membership-and-administration.md | 60 ++++ hub/docs/testing-and-operations.md | 2 +- hub/schema/external-identity.prisma | 1 + hub/schema/identity-store-state.prisma | 3 +- hub/schema/installation-membership.prisma | 12 +- hub/schema/membership-invitation.prisma | 31 +++ .../migration.sql | 98 +++++++ hub/schema/security-audit.prisma | 12 +- hub/schema/user.prisma | 1 + hub/server/create-app/index.ts | 2 +- .../create-service/index.db.test.ts | 103 +++++++ .../administration/create-service/index.ts | 261 ++++++++++++++++++ .../administration/create-service/types.ts | 22 ++ .../complete-github-authorization/index.ts | 168 +++++++++-- .../create-admission-policy/index.ts | 2 +- .../authentication/create-runtime/index.ts | 18 ++ .../create-service/index.test.ts | 142 +++++++++- .../create-service/membership.test.ts | 78 ++++++ .../authentication/create-service/types.ts | 7 +- .../get-current-session/index.ts | 3 + .../authentication/revoke-sessions/index.ts | 1 + .../domains/authentication/sign-out/index.ts | 1 + .../start-github-authorization/index.ts | 25 +- .../domains/identity/create-records/index.ts | 26 ++ .../domains/identity/create-store/types.ts | 32 ++- .../respond-administration-error/index.ts | 20 ++ .../authorization-attempts/types.ts | 2 +- .../complete-bootstrap/index.ts | 14 + .../get-bootstrap-completed-at/index.ts | 11 + .../count-active-administrators/index.ts | 7 + .../get-membership-by-id/index.ts | 12 + .../get-membership/index.ts | 10 +- .../get-memberships/index.ts | 12 + .../save-membership/index.ts | 13 +- .../installation-memberships/types.ts | 20 +- .../consume-invitation/index.ts | 15 + .../create-invitation/index.ts | 15 + .../get-invitation/index.ts | 11 + .../get-invitations/index.ts | 11 + .../get-usable-invitation/index.ts | 22 ++ .../revoke-expired-invitations/index.ts | 11 + .../revoke-invitation/index.ts | 13 + .../membership-invitations/types.ts | 17 ++ .../security-audits/get-audits/index.ts | 5 +- .../security-audits/save-audit/index.ts | 21 +- .../repositories/security-audits/types.ts | 17 ++ .../sessions/delete-user-sessions/index.ts | 5 + hub/server/routes/administration.test.ts | 154 +++++++++++ .../routes/api/administration/get/index.ts | 16 ++ .../invitations/delete/index.ts | 23 ++ .../administration/invitations/post/index.ts | 23 ++ .../administration/memberships/patch/index.ts | 24 ++ .../routes/api/auth/enrollment/post/index.ts | 33 +++ hub/server/routes/api/session/get/index.ts | 5 + hub/server/routes/authentication.test.ts | 17 +- hub/server/routes/index.ts | 30 ++ hub/server/routes/types.ts | 2 + 74 files changed, 2320 insertions(+), 63 deletions(-) create mode 100644 hub/app/javascript/pages/membership-administration-page/index.test.tsx create mode 100644 hub/app/javascript/pages/membership-administration-page/index.tsx create mode 100644 hub/app/javascript/pages/membership-administration-page/stories.tsx create mode 100644 hub/app/javascript/pages/membership-administration-page/styles.css create mode 100644 hub/app/javascript/pages/membership-administration-page/types.ts create mode 100644 hub/docs/membership-and-administration.md create mode 100644 hub/schema/membership-invitation.prisma create mode 100644 hub/schema/migrations/20261027210000_membership_administration/migration.sql create mode 100644 hub/server/domains/administration/create-service/index.db.test.ts create mode 100644 hub/server/domains/administration/create-service/index.ts create mode 100644 hub/server/domains/administration/create-service/types.ts create mode 100644 hub/server/domains/authentication/create-service/membership.test.ts create mode 100644 hub/server/integrations/express/respond-administration-error/index.ts create mode 100644 hub/server/repositories/identity-store-state/complete-bootstrap/index.ts create mode 100644 hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts create mode 100644 hub/server/repositories/installation-memberships/count-active-administrators/index.ts create mode 100644 hub/server/repositories/installation-memberships/get-membership-by-id/index.ts create mode 100644 hub/server/repositories/installation-memberships/get-memberships/index.ts create mode 100644 hub/server/repositories/membership-invitations/consume-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/create-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/get-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/get-invitations/index.ts create mode 100644 hub/server/repositories/membership-invitations/get-usable-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts create mode 100644 hub/server/repositories/membership-invitations/revoke-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/types.ts create mode 100644 hub/server/repositories/sessions/delete-user-sessions/index.ts create mode 100644 hub/server/routes/administration.test.ts create mode 100644 hub/server/routes/api/administration/get/index.ts create mode 100644 hub/server/routes/api/administration/invitations/delete/index.ts create mode 100644 hub/server/routes/api/administration/invitations/post/index.ts create mode 100644 hub/server/routes/api/administration/memberships/patch/index.ts create mode 100644 hub/server/routes/api/auth/enrollment/post/index.ts diff --git a/hub/.env.example b/hub/.env.example index 3b33a5dc..33368342 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -24,6 +24,11 @@ TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres # GitHub proves identity by default. Select legacy-repository only for compatibility. HUB_ADMISSION_POLICY=hub-managed +# Generate separate host-held values. Bootstrap can enroll the first administrator once. +# Recovery works only when no active administrator remains and never reopens bootstrap. +# HUB_BOOTSTRAP_SECRET= +# HUB_ADMIN_RECOVERY_SECRET= + # Select the GitHub source-integration credential provider. See docs/identity.md. HUB_GITHUB_CREDENTIAL_PROVIDER=database diff --git a/hub/README.md b/hub/README.md index b6bfa5d8..2ea2df92 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,9 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API exposes its health check and GitHub authentication and session routes. The frontend provides the minimal GitHub sign-in and sign-out surface with light and dark themes. Existing components, features, and pages remain in Storybook, but they are not connected to product APIs. +The API exposes its health check, GitHub authentication, sessions, and installation membership administration. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. GitHub sign-in works when its dedicated client credentials are configured; invitations, membership administration, product authorization, saved product data, live updates, background jobs, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, invitations, membership roles and lifecycle, administrator recovery, and session revocation are implemented. Prototype authorization, saved prototype data, live updates, background jobs, and deployment are not implemented. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx index cbffc8e0..48e9f5e6 100644 --- a/hub/app/javascript/application/authentication/index.tsx +++ b/hub/app/javascript/application/authentication/index.tsx @@ -2,12 +2,27 @@ import { useEffect, useState } from "react" import { SignInPage } from "@pages/sign-in-page" import type { SignInPageUser } from "@pages/sign-in-page/types" +import { MembershipAdministrationPage } from "@pages/membership-administration-page" +import type { + InvitationRecord, + MembershipRecord, +} from "@pages/membership-administration-page/types" interface SessionResponse { authenticated: boolean configured: boolean csrfToken: string user?: SignInPageUser + membership?: { + id: string + role: "member" | "administrator" + status: "active" + } +} + +interface AdministrationResponse { + memberships: MembershipRecord[] + invitations: InvitationRecord[] } async function fetchSession(): Promise { @@ -20,10 +35,18 @@ export function AuthenticationApplication() { const [session, setSession] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const [administration, setAdministration] = useState(null) useEffect(() => { fetchSession() - .then(setSession) + .then(async (value) => { + setSession(value) + if (value.membership?.role === "administrator") { + const response = await fetch("/api/administration", { credentials: "same-origin" }) + if (!response.ok) throw new Error("Administration request failed") + setAdministration((await response.json()) as AdministrationResponse) + } + }) .catch(() => setError("Hub could not load your sign-in status.")) .finally(() => setLoading(false)) }, []) @@ -47,6 +70,29 @@ export function AuthenticationApplication() { } } + async function startEnrollment(purpose: "bootstrap" | "recovery", secret: string) { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch(`/api/auth/${purpose}`, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": session.csrfToken, + }, + body: JSON.stringify({ secret }), + }) + if (!response.ok) throw new Error("Enrollment is unavailable.") + const data = (await response.json()) as { authorizationUrl: string } + window.location.assign(data.authorizationUrl) + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Enrollment is unavailable.") + setLoading(false) + } + } + async function signOut() { if (!session) return setLoading(true) @@ -59,6 +105,7 @@ export function AuthenticationApplication() { }) if (!response.ok) throw new Error("Sign-out request failed") setSession(await fetchSession()) + setAdministration(null) } catch { setError("Hub could not sign you out.") } finally { @@ -66,6 +113,67 @@ export function AuthenticationApplication() { } } + async function updateAdministration(path: string, method: string, body?: unknown) { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch(path, { + method, + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": session.csrfToken, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (!response.ok) { + const data = (await response.json()) as { error?: string } + throw new Error(data.error ?? "Administration request failed") + } + const current = await fetchSession() + setSession(current) + if (current.membership?.role === "administrator") { + const refreshed = await fetch("/api/administration", { credentials: "same-origin" }) + if (!refreshed.ok) throw new Error("Administration request failed") + setAdministration((await refreshed.json()) as AdministrationResponse) + } else { + setAdministration(null) + } + } catch (requestError) { + setError( + requestError instanceof Error ? requestError.message : "Hub could not update membership.", + ) + } finally { + setLoading(false) + } + } + + if (session?.authenticated && session.membership?.role === "administrator" && administration) { + return ( + + void updateAdministration("/api/administration/invitations", "POST", input) + } + onChange={(membershipId, input) => + void updateAdministration( + `/api/administration/memberships/${membershipId}`, + "PATCH", + input, + ) + } + onRevokeInvitation={(invitationId) => + void updateAdministration(`/api/administration/invitations/${invitationId}`, "DELETE") + } + onSignOut={() => void signOut()} + /> + ) + } + return ( void signIn()} onSignOut={() => void signOut()} + onBootstrap={(secret) => void startEnrollment("bootstrap", secret)} + onRecovery={(secret) => void startEnrollment("recovery", secret)} /> ) } diff --git a/hub/app/javascript/pages/membership-administration-page/index.test.tsx b/hub/app/javascript/pages/membership-administration-page/index.test.tsx new file mode 100644 index 00000000..ab291bd7 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.test.tsx @@ -0,0 +1,53 @@ +import { ThemeProvider } from "@primer/react-brand" +import { fireEvent, render, screen, within } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { MembershipAdministrationPage } from "." + +describe("MembershipAdministrationPage()", () => { + it("offers restoration for suspension but no lifecycle actions after removal", () => { + const onChange = vi.fn() + render( + + + , + ) + + const suspended = screen.getByText("Suspended member").closest("li")! + fireEvent.click(within(suspended).getByRole("button", { name: "Restore" })) + expect(onChange).toHaveBeenCalledWith("suspended", { status: "active" }) + + const removed = screen.getByText("Removed administrator").closest("li")! + expect(within(removed).queryByRole("button")).not.toBeInTheDocument() + }) +}) diff --git a/hub/app/javascript/pages/membership-administration-page/index.tsx b/hub/app/javascript/pages/membership-administration-page/index.tsx new file mode 100644 index 00000000..478fc56e --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.tsx @@ -0,0 +1,204 @@ +import { useState } from "react" + +import { Button, FormControl, Heading, Select, Stack, Text, TextInput } from "@primer/react-brand" + +import type { MembershipAdministrationPageProps } from "./types" +import "./styles.css" + +export function MembershipAdministrationPage({ + memberships, + invitations, + loading, + error, + onInvite, + onChange, + onRevokeInvitation, + onSignOut, +}: MembershipAdministrationPageProps) { + const [provider, setProvider] = useState("github") + const [issuer, setIssuer] = useState("https://github.com") + const [subject, setSubject] = useState("") + const [role, setRole] = useState<"member" | "administrator">("administrator") + + return ( +
+
+
+ + Hub membership + + + Hub admission and installation roles are independent of repository access. + +
+ +
+ + {error ? ( + + {error} + + ) : null} + +
+ + Invite a provider identity + +
{ + event.preventDefault() + onInvite({ provider, issuer, subject, role }) + }} + > + + Provider + setProvider(event.target.value)} + /> + + + Issuer + setIssuer(event.target.value)} + /> + + + Immutable subject + setSubject(event.target.value)} + /> + + + Role + + + +
+
+ +
+ + Memberships + +
    + {memberships.map((membership) => { + const identity = membership.user.externalIdentities[0] + return ( +
  • + + + {membership.user.displayName ?? + identity?.username ?? + identity?.subject ?? + membership.user.id} + + + {identity + ? `${identity.provider} ยท ${identity.issuer} ยท ${identity.subject}` + : "No external identity"} + + + {membership.role} ยท {membership.status} + + {membership.status !== "removed" ? ( +
    + + + +
    + ) : null} +
    +
  • + ) + })} +
+
+ +
+ + Invitations + +
    + {invitations.map((invitation) => ( +
  • + {invitation.subject} + + {invitation.provider} ยท {invitation.issuer} ยท {invitation.role} + + {!invitation.consumedAt && !invitation.revokedAt ? ( + + ) : ( + {invitation.consumedAt ? "Activated" : "Revoked"} + )} +
  • + ))} +
+
+
+ ) +} diff --git a/hub/app/javascript/pages/membership-administration-page/stories.tsx b/hub/app/javascript/pages/membership-administration-page/stories.tsx new file mode 100644 index 00000000..1ab480c5 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { MembershipAdministrationPage } from "." + +const meta = { + title: "Hub/Pages/Identity/Membership administration", + component: MembershipAdministrationPage, + parameters: { layout: "fullscreen" }, + args: { + loading: false, + error: null, + memberships: [ + { + id: "membership-1", + role: "administrator", + status: "active", + user: { + id: "user-1", + displayName: "Mona Lisa", + externalIdentities: [ + { + id: "identity-1", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "mona", + }, + ], + }, + }, + { + id: "membership-2", + role: "member", + status: "suspended", + user: { + id: "user-2", + displayName: "Ada Lovelace", + externalIdentities: [ + { + id: "identity-2", + provider: "oidc", + issuer: "https://login.example", + subject: "ada-1843", + username: "ada", + }, + ], + }, + }, + ], + invitations: [ + { + id: "invitation-1", + provider: "github", + issuer: "https://github.com", + subject: "99", + role: "administrator", + expiresAt: "2026-11-03T20:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + onInvite: () => undefined, + onChange: () => undefined, + onRevokeInvitation: () => undefined, + onSignOut: () => undefined, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/hub/app/javascript/pages/membership-administration-page/styles.css b/hub/app/javascript/pages/membership-administration-page/styles.css new file mode 100644 index 00000000..8dc46b59 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/styles.css @@ -0,0 +1,45 @@ +.membership-administration-page { + display: grid; + gap: 2rem; + max-inline-size: 72rem; + margin-inline: auto; + padding: 2rem; +} + +.membership-administration-page__header { + display: flex; + gap: 1rem; + align-items: start; + justify-content: space-between; +} + +.membership-administration-page__form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; + align-items: end; + margin-block-start: 1rem; +} + +.membership-administration-page__list { + display: grid; + gap: 0.75rem; + padding: 0; + list-style: none; +} + +.membership-administration-page__list > li { + padding: 1rem; + border: 0.0625rem solid var(--hub-border-default); + border-radius: 0.5rem; +} + +.membership-administration-page__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.membership-administration-page__error { + color: var(--hub-fg-danger); +} diff --git a/hub/app/javascript/pages/membership-administration-page/types.ts b/hub/app/javascript/pages/membership-administration-page/types.ts new file mode 100644 index 00000000..9498ffa5 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/types.ts @@ -0,0 +1,48 @@ +export interface MembershipIdentity { + id: string + provider: string + issuer: string + subject: string + username: string | null +} + +export interface MembershipRecord { + id: string + status: "active" | "suspended" | "removed" + role: "member" | "administrator" + user: { + id: string + displayName: string | null + externalIdentities: MembershipIdentity[] + } +} + +export interface InvitationRecord { + id: string + provider: string + issuer: string + subject: string + role: "member" | "administrator" + expiresAt: string + consumedAt: string | null + revokedAt: string | null +} + +export interface MembershipAdministrationPageProps { + memberships: MembershipRecord[] + invitations: InvitationRecord[] + loading: boolean + error: string | null + onInvite: (input: { + provider: string + issuer: string + subject: string + role: "member" | "administrator" + }) => void + onChange: ( + membershipId: string, + input: { role?: "member" | "administrator"; status?: "active" | "suspended" | "removed" }, + ) => void + onRevokeInvitation: (invitationId: string) => void + onSignOut: () => void +} diff --git a/hub/app/javascript/pages/sign-in-page/index.test.tsx b/hub/app/javascript/pages/sign-in-page/index.test.tsx index d2d9ea47..b16bd1bd 100644 --- a/hub/app/javascript/pages/sign-in-page/index.test.tsx +++ b/hub/app/javascript/pages/sign-in-page/index.test.tsx @@ -14,6 +14,8 @@ describe("SignInPage()", () => { error={null} onSignIn={onSignIn} onSignOut={() => undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} />, ) @@ -32,6 +34,8 @@ describe("SignInPage()", () => { error={null} onSignIn={() => undefined} onSignOut={() => undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} />, ) @@ -48,6 +52,8 @@ describe("SignInPage()", () => { error={null} onSignIn={() => undefined} onSignOut={() => undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} />, ) diff --git a/hub/app/javascript/pages/sign-in-page/index.tsx b/hub/app/javascript/pages/sign-in-page/index.tsx index 15907c79..53b8fd7c 100644 --- a/hub/app/javascript/pages/sign-in-page/index.tsx +++ b/hub/app/javascript/pages/sign-in-page/index.tsx @@ -1,4 +1,6 @@ -import { Button, Heading, Stack, Text } from "@primer/react-brand" +import { useState } from "react" + +import { Button, FormControl, Heading, Stack, Text, TextInput } from "@primer/react-brand" import { ReplicaLogo } from "@components/replica-logo" @@ -12,7 +14,11 @@ export function SignInPage({ error, onSignIn, onSignOut, + onBootstrap, + onRecovery, }: SignInPageProps) { + const [bootstrapSecret, setBootstrapSecret] = useState("") + const [recoverySecret, setRecoverySecret] = useState("") return (
@@ -47,6 +53,50 @@ export function SignInPage({ {!configured && ( GitHub sign-in has not been configured for this Hub. )} + {configured ? ( +
+
+ Enroll the first administrator + + Bootstrap secret + setBootstrapSecret(event.target.value)} + /> + + +
+
+ Recover administration + + Recovery secret + setRecoverySecret(event.target.value)} + /> + + +
+
+ ) : null} )} {error && ( diff --git a/hub/app/javascript/pages/sign-in-page/stories.tsx b/hub/app/javascript/pages/sign-in-page/stories.tsx index 2e0baf07..a2fbc192 100644 --- a/hub/app/javascript/pages/sign-in-page/stories.tsx +++ b/hub/app/javascript/pages/sign-in-page/stories.tsx @@ -13,6 +13,8 @@ const meta = { error: null, onSignIn: () => undefined, onSignOut: () => undefined, + onBootstrap: () => undefined, + onRecovery: () => undefined, }, } satisfies Meta diff --git a/hub/app/javascript/pages/sign-in-page/styles.css b/hub/app/javascript/pages/sign-in-page/styles.css index 77bb16d8..e0632f58 100644 --- a/hub/app/javascript/pages/sign-in-page/styles.css +++ b/hub/app/javascript/pages/sign-in-page/styles.css @@ -13,3 +13,27 @@ .sign-in-page__error { color: var(--hub-fg-danger); } + +.sign-in-page__enrollment { + display: grid; + gap: 0.75rem; + inline-size: min(100%, 28rem); + text-align: start; +} + +.sign-in-page__enrollment details { + display: grid; + gap: 0.75rem; + padding: 0.75rem; + border: 0.0625rem solid var(--hub-border-default); + border-radius: 0.5rem; +} + +.sign-in-page__enrollment summary { + cursor: pointer; + font-weight: 600; +} + +.sign-in-page__enrollment details[open] > * + * { + margin-block-start: 0.75rem; +} diff --git a/hub/app/javascript/pages/sign-in-page/types.ts b/hub/app/javascript/pages/sign-in-page/types.ts index d12b098c..e32f4081 100644 --- a/hub/app/javascript/pages/sign-in-page/types.ts +++ b/hub/app/javascript/pages/sign-in-page/types.ts @@ -11,4 +11,6 @@ export interface SignInPageProps { error: string | null onSignIn: () => void onSignOut: () => void + onBootstrap: (secret: string) => void + onRecovery: (secret: string) => void } diff --git a/hub/docs/README.md b/hub/docs/README.md index 069c4073..55894251 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -8,6 +8,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. - [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. +- [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 52f58790..cd180278 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check and provides GitHub authentication and Hub session routes. The frontend displays the sign-in state with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub authentication, Hub sessions, and installation membership administration. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page 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 system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization and provider-neutral Hub sessions work when user-auth credentials are configured. Deployment, background jobs, and product APIs have not been built. This app cannot yet 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 system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. Deployment, background jobs, and prototype APIs have not been built. This app cannot yet evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication runtime and starts the API at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication and administration runtime and starts the API at a local address. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. @@ -35,7 +35,7 @@ Keep the existing `app/javascript/` layout. Application code starts React and wi Imports follow the arrows above. Shared components, hooks, and utilities must not import pages, features, or application code. Keep browser and server code separate. Agree on the API before adding types shared between them. -The frontend displays the GitHub sign-in and session state inside the theme provider. Existing pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. +The frontend displays GitHub sign-in and session state inside the theme provider. An active administrator sees the minimal membership and invitation page. Existing prototype pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. ## Other projects diff --git a/hub/docs/development.md b/hub/docs/development.md index 43ffbe47..dfe38c65 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -20,7 +20,7 @@ cp .env.example .env npm ci --engine-strict ``` -The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in; leave both unset to run Hub with sign-in disabled. +The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in; leave both unset to run Hub with sign-in disabled. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. ## Start the app diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 72fd0947..08691490 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,6 +1,6 @@ # Hub identity -Hub uses GitHub user authorization to prove an external identity and creates provider-neutral browser sessions. Hub installation membership records administrative roles. Later authorization work will let Hub own prototype access independently of repository permissions. +Hub uses GitHub user authorization to prove an external identity. Hub then applies its own installation admission and membership rules before creating a provider-neutral browser session. Repository permissions do not grant installation membership. ## GitHub user authentication @@ -12,21 +12,30 @@ The callback consumes an attempt once, checks the same-browser binding and curre Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. -`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, and no repository permission check gates sign-in. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to require access to at least one installed repository. The GitHub authentication adapter never reads installation credentials or performs repository authorization. +`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to create a member assignment after repository admission. The GitHub authentication adapter never reads installation credentials or performs repository authorization. -Sign-out revokes the current database session. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with verification less than five minutes old may revoke all sessions; revocation also supersedes callbacks that started earlier. +Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. ## 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. +- `InstallationMembership` stores a user's `member` or `administrator` role and `active`, `suspended`, or terminal `removed` status for this Hub installation. +- `MembershipInvitation` targets an `ExternalIdentity` assignment by provider, issuer, and immutable subject. Activation consumes the invitation once and creates or reactivates the linked user's membership. - `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. +Store operations can share one database transaction and lock a fixed store-state row. Related membership, invitation, session, and audit changes therefore commit or roll back together. Overlapping final-administrator changes and invitation activations observe committed state. + +## Installation admission and administration + +Set separate host-held `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values. Bootstrap can enroll the first administrator once. Its durable completion marker remains after expired authorization attempts are cleaned up. Recovery is available only when no active administrator remains and never resets bootstrap. + +Administrators invite an identity by provider, issuer, and immutable subject. Mutable usernames, display names, and email addresses do not activate invitations. Invitations expire after seven days, can be revoked before use, and are consumed by one matching authentication callback. Removed memberships require a new invitation. Suspended memberships must be restored by an administrator. + +Administrator mutations require an active administrator session verified less than five minutes ago. Hub prevents concurrent demotion, suspension, or removal from eliminating the final active administrator. See [Installation membership and administration](membership-and-administration.md). ## GitHub source-integration credentials @@ -43,6 +52,6 @@ Database credentials use AES-256-GCM with contextual additional authenticated da ## Current scope -This layer adds GitHub sign-in, session restoration, sign-out, fresh verification, global session revocation, and the minimal frontend needed to use them. It does not add invitations or membership administration, prototype authorization, GitHub permission synchronization, artifact ingestion, OIDC, account linking, SCIM, or product APIs. +This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. It does not add prototype, repository, workspace, or artifact roles; GitHub membership synchronization; artifact ingestion; production OIDC; account linking; SCIM; or prototype APIs. Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md new file mode 100644 index 00000000..0408e3c2 --- /dev/null +++ b/hub/docs/membership-and-administration.md @@ -0,0 +1,60 @@ +# Installation membership and administration + +Hub owns installation admission after an authentication provider proves an identity. Repository access and installation membership are separate decisions. + +## Membership contract + +Each Hub user has at most one installation membership. + +| State | Result | +| --- | --- | +| Active member | May create and restore a Hub session. Product access remains a separate future decision. | +| Active administrator | Has member access and may use installation administration after fresh identity verification. | +| Suspended | Cannot create or use a Hub session. An administrator may restore the membership. | +| Removed | Cannot create or use a Hub session. The retained record preserves audit linkage, and return requires a new invitation. | + +Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. + +## Invitations + +An invitation targets an external identity assignment: + +```text +provider + issuer + immutable subject -> ExternalIdentity -> User -> InstallationMembership +``` + +An administrator may invite a member or administrator. Hub never matches mutable login, display-name, or email fields. The invitation expires after seven days, can be revoked before activation, and can be consumed once. A matching authentication callback activates the membership and creates the session in one serialized operation. + +Hub rejects invitations for active or suspended assignments. A removed assignment may receive a new invitation. + +## Bootstrap and recovery + +Set two independent host-held secrets: + +- `HUB_BOOTSTRAP_SECRET` authorizes the first administrator enrollment once. +- `HUB_ADMIN_RECOVERY_SECRET` authorizes recovery only when no active administrator remains. + +Both routes verify the secret before starting a browser-bound, one-use provider authorization. The callback must complete in that browser. Hub stores neither secret in the database. Bootstrap has a durable completion marker and cannot reopen after attempt cleanup. Recovery does not change that marker. + +Keep both values outside Git and transfer them through the deployment owner's approved secret-management process. + +## Administration API + +The minimal administration surface uses: + +- `GET /api/administration` +- `POST /api/administration/invitations` +- `DELETE /api/administration/invitations/:id` +- `PATCH /api/administration/memberships/:id` +- `POST /api/auth/bootstrap` +- `POST /api/auth/recovery` + +State-changing requests require the configured origin and matching CSRF cookie/header. Membership and invitation changes also require an active administrator session verified less than five minutes ago. Administration errors return stable codes such as `administrator_required`, `last_administrator`, and `invitation_changed`. + +## Security records + +Membership operations write a structured security record with a bounded action, outcome, actor, session, target membership or invitation, and fixed reason context. Success records commit with their state changes. Records never include host secrets, provider tokens, request payloads, mutable identity labels, or exception text. + +## Non-goals + +This boundary does not define prototype, repository, workspace, or artifact roles. It does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 724dc1a0..a3887a60 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. Authentication cases also cover PKCE, browser binding, provider revisions, revocation races, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. +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. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. 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/external-identity.prisma b/hub/schema/external-identity.prisma index 908e4cfa..8c7b25df 100644 --- a/hub/schema/external-identity.prisma +++ b/hub/schema/external-identity.prisma @@ -6,6 +6,7 @@ model ExternalIdentity { // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) sessions Session[] + activatedInvitations MembershipInvitation[] @relation("ActivatedIdentity") // Attribute columns provider String diff --git a/hub/schema/identity-store-state.prisma b/hub/schema/identity-store-state.prisma index 1b5ca274..badf58f7 100644 --- a/hub/schema/identity-store-state.prisma +++ b/hub/schema/identity-store-state.prisma @@ -3,7 +3,8 @@ model IdentityStoreState { id String @id @db.Uuid // Attribute columns - sessionGeneration Int @default(0) @map("session_generation") + sessionGeneration Int @default(0) @map("session_generation") + bootstrapCompletedAt DateTime? @map("bootstrap_completed_at") @db.Timestamptz(3) // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) diff --git a/hub/schema/installation-membership.prisma b/hub/schema/installation-membership.prisma index da4ea594..623d965f 100644 --- a/hub/schema/installation-membership.prisma +++ b/hub/schema/installation-membership.prisma @@ -4,12 +4,16 @@ model InstallationMembership { userId String @map("user_id") @db.Uuid // Relations - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + invitations MembershipInvitation[] @relation("ActivatedMembership") + auditTargets SecurityAudit[] @relation("TargetMembership") // Attribute columns - status String - role String - source String + status String + role String + source String + suspendedAt DateTime? @map("suspended_at") @db.Timestamptz(3) + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) diff --git a/hub/schema/membership-invitation.prisma b/hub/schema/membership-invitation.prisma new file mode 100644 index 00000000..3e8e61e2 --- /dev/null +++ b/hub/schema/membership-invitation.prisma @@ -0,0 +1,31 @@ +model MembershipInvitation { + // ID columns + id String @id @default(uuid()) @db.Uuid + createdByUserId String? @map("created_by_user_id") @db.Uuid + activatedIdentityId String? @map("activated_identity_id") @db.Uuid + activatedMembershipId String? @map("activated_membership_id") @db.Uuid + + // Relations + createdByUser User? @relation("InvitationCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + activatedIdentity ExternalIdentity? @relation("ActivatedIdentity", fields: [activatedIdentityId], references: [id], onDelete: SetNull) + activatedMembership InstallationMembership? @relation("ActivatedMembership", fields: [activatedMembershipId], references: [id], onDelete: SetNull) + audits SecurityAudit[] + + // Attribute columns + provider String + issuer String + subject String + role String + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_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([provider, issuer, subject]) + @@index([expiresAt]) + @@index([createdByUserId]) + @@map("membership_invitations") +} diff --git a/hub/schema/migrations/20261027210000_membership_administration/migration.sql b/hub/schema/migrations/20261027210000_membership_administration/migration.sql new file mode 100644 index 00000000..55025496 --- /dev/null +++ b/hub/schema/migrations/20261027210000_membership_administration/migration.sql @@ -0,0 +1,98 @@ +-- AlterTable +ALTER TABLE "identity_store_states" ADD COLUMN "bootstrap_completed_at" TIMESTAMPTZ(3); + +-- AlterTable +ALTER TABLE "installation_memberships" +ADD COLUMN "suspended_at" TIMESTAMPTZ(3), +ADD COLUMN "removed_at" TIMESTAMPTZ(3); + +-- Existing foundation records are active and must satisfy the lifecycle constraint. +UPDATE "installation_memberships" +SET "suspended_at" = CASE WHEN "status" = 'suspended' THEN CURRENT_TIMESTAMP ELSE NULL END; + +-- Replace the cascading user relation so retained membership and audit linkage cannot disappear. +ALTER TABLE "installation_memberships" DROP CONSTRAINT "installation_memberships_user_id_fkey"; +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_status" CHECK ("status" IN ('active', 'suspended', 'removed')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_role" CHECK ("role" IN ('member', 'administrator')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_source" CHECK ("source" IN ('bootstrap', 'invitation', 'manual', 'recovery', 'synchronized')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_lifecycle" CHECK ( + ("status" = 'active' AND "suspended_at" IS NULL AND "removed_at" IS NULL) OR + ("status" = 'suspended' AND "suspended_at" IS NOT NULL AND "removed_at" IS NULL) OR + ("status" = 'removed' AND "removed_at" IS NOT NULL) +); + +-- CreateTable +CREATE TABLE "membership_invitations" ( + "id" UUID NOT NULL, + "created_by_user_id" UUID, + "activated_identity_id" UUID, + "activated_membership_id" UUID, + "provider" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "role" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "consumed_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "membership_invitations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "membership_invitations_role" CHECK ("role" IN ('member', 'administrator')), + CONSTRAINT "membership_invitations_lifecycle" CHECK (NOT ("consumed_at" IS NOT NULL AND "revoked_at" IS NOT NULL)) +); + +-- CreateIndex +CREATE INDEX "membership_invitations_provider_issuer_subject_idx" ON "membership_invitations"("provider", "issuer", "subject"); + +-- CreateIndex +CREATE INDEX "membership_invitations_expires_at_idx" ON "membership_invitations"("expires_at"); + +-- CreateIndex +CREATE INDEX "membership_invitations_created_by_user_id_idx" ON "membership_invitations"("created_by_user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "membership_invitations_usable_assignment_key" ON "membership_invitations"("provider", "issuer", "subject") WHERE "consumed_at" IS NULL AND "revoked_at" IS NULL; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_activated_identity_id_fkey" FOREIGN KEY ("activated_identity_id") REFERENCES "external_identities"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_activated_membership_id_fkey" FOREIGN KEY ("activated_membership_id") REFERENCES "installation_memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "security_audits" +ADD COLUMN "target_membership_id" UUID, +ADD COLUMN "invitation_id" UUID, +ADD COLUMN "outcome" TEXT NOT NULL DEFAULT 'succeeded', +ADD COLUMN "context" JSONB NOT NULL DEFAULT '{}'; + +-- AddCheckConstraint +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_outcome" CHECK ("outcome" IN ('succeeded', 'denied', 'failed')); + +-- AddCheckConstraint +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_context" CHECK (jsonb_typeof("context") = 'object'); + +-- CreateIndex +CREATE INDEX "security_audits_target_membership_id_idx" ON "security_audits"("target_membership_id"); + +-- CreateIndex +CREATE INDEX "security_audits_invitation_id_idx" ON "security_audits"("invitation_id"); + +-- AddForeignKey +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_target_membership_id_fkey" FOREIGN KEY ("target_membership_id") REFERENCES "installation_memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_invitation_id_fkey" FOREIGN KEY ("invitation_id") REFERENCES "membership_invitations"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index 8d46e5e8..e3bcd5ab 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -4,14 +4,24 @@ model SecurityAudit { actorUserId String? @map("actor_user_id") @db.Uuid sessionId String? @map("session_id") @db.Uuid authenticationProviderRevisionId String? @map("authentication_provider_revision_id") @db.Uuid + targetMembershipId String? @map("target_membership_id") @db.Uuid + invitationId String? @map("invitation_id") @db.Uuid + + // Relations + targetMembership InstallationMembership? @relation("TargetMembership", fields: [targetMembershipId], references: [id], onDelete: SetNull) + invitation MembershipInvitation? @relation(fields: [invitationId], references: [id], onDelete: SetNull) // Attribute columns - event String + event String + outcome String @default("succeeded") + context Json @default("{}") // 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]) + @@index([targetMembershipId]) + @@index([invitationId]) @@map("security_audits") } diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma index 73ed59e8..8ff2cd32 100644 --- a/hub/schema/user.prisma +++ b/hub/schema/user.prisma @@ -6,6 +6,7 @@ model User { externalIdentities ExternalIdentity[] memberships InstallationMembership[] sessions Session[] + createdInvitations MembershipInvitation[] @relation("InvitationCreator") // Attribute columns displayName String? @map("display_name") diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index ef54e6b7..a78512b8 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -17,7 +17,7 @@ export function createApp({ app.disable("x-powered-by") app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) - app.use(["/api/auth", "/api/session"], omitRequestLogPayload) + app.use(["/api/auth", "/api/session", "/api/administration"], omitRequestLogPayload) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts new file mode 100644 index 00000000..2a8e3133 --- /dev/null +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createAdministrationService } from "#server/domains/administration/create-service" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { prisma, store: createIdentityStore(prisma) } +} + +async function createAdministrator(store: IdentityStore, subject: string, token: string) { + const authorizedAt = new Date("2026-10-27T18:00:00.000Z") + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `Administrator ${subject}`, + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `administrator-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role: "administrator", + source: "manual", + suspendedAt: null, + removedAt: null, + }) + const session = await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }) + return { membership, session, user } + }) +} + +describe("createAdministrationService() database behavior", () => { + it("serializes overlapping suspensions so one active administrator and its session remain", async () => { + const { store } = await database() + const first = await createAdministrator(store, "41", "first-token") + const second = await createAdministrator(store, "42", "second-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + const results = await Promise.allSettled([ + service.changeMembership("first-token", second.membership.id, { + status: "suspended", + }), + service.changeMembership("second-token", first.membership.id, { + status: "suspended", + }), + ]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + expect(await store.read.activeAdministratorCount()).toBe(1) + + const memberships = await store.read.memberships() + const active = memberships.find(({ status }) => status === "active") + const suspended = memberships.find(({ status }) => status === "suspended") + expect(active).toBeDefined() + expect(suspended).toBeDefined() + const activeToken = active?.id === first.membership.id ? "first-token" : "second-token" + const suspendedToken = suspended?.id === first.membership.id ? "first-token" : "second-token" + expect(await store.read.session(digestSecret(activeToken))).toBeTruthy() + expect(await store.read.session(digestSecret(suspendedToken))).toBeNull() + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "membership.change", + outcome: "succeeded", + targetMembershipId: suspended?.id, + context: { reason: "status_suspended" }, + }), + ]) + }) +}) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts new file mode 100644 index 00000000..4a96589a --- /dev/null +++ b/hub/server/domains/administration/create-service/index.ts @@ -0,0 +1,261 @@ +import { z } from "zod" + +import type { + AdministrationDependencies, + AdministrationService, +} from "#server/domains/administration/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" + +const invitationSchema = z.object({ + provider: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/), + issuer: z.url().max(500), + subject: z.string().trim().min(1).max(500), + role: z.enum(["member", "administrator"]), +}) + +const membershipChangeSchema = z + .object({ + role: z.enum(["member", "administrator"]).optional(), + status: z.enum(["active", "suspended", "removed"]).optional(), + }) + .refine(({ role, status }) => role !== undefined || status !== undefined) + +function requireFreshAdministrator( + dependencies: AdministrationDependencies, + sessionToken: string, + fresh = true, +) { + return dependencies.store.lock(async (records) => { + const current = await getCurrentSession( + { + store: dependencies.store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + now: dependencies.now, + }, + sessionToken, + records, + ) + if (current.membership.role !== "administrator") { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + if ( + fresh && + (!current.freshAuthorizedAt || + dependencies.now().getTime() - current.freshAuthorizedAt.getTime() >= 5 * 60_000) + ) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Verify your identity again.", + ) + } + return current + }) +} + +export function createAdministrationService( + options: Omit & { now?: () => Date }, +): AdministrationService { + const dependencies: AdministrationDependencies = { + ...options, + now: options.now ?? (() => new Date()), + } + + return { + async list(sessionToken) { + await requireFreshAdministrator(dependencies, sessionToken, false) + const [memberships, invitations] = await Promise.all([ + dependencies.store.read.memberships(), + dependencies.store.read.invitations(), + ]) + return { memberships, invitations } + }, + + async invite(sessionToken, input) { + const parsed = invitationSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_invitation", "The invitation is invalid.") + } + const current = await requireFreshAdministrator(dependencies, sessionToken) + return dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + await records.revokeExpiredInvitations(dependencies.now()) + const identity = await records.externalIdentity( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + ) + const membership = identity ? await records.membership(identity.userId) : null + if (membership && membership.status !== "removed") { + throw new AuthenticationError( + 409, + "identity_already_assigned", + "This identity already has a membership.", + ) + } + const existing = await records.usableInvitation( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + dependencies.now(), + ) + if (existing) { + throw new AuthenticationError(409, "invitation_exists", "An invitation already exists.") + } + const invitation = await records.createInvitation({ + ...parsed.data, + createdByUserId: actor.userId, + expiresAt: new Date(dependencies.now().getTime() + 7 * 24 * 60 * 60_000), + }) + await records.audit({ + event: "membership.invitation.create", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + invitationId: invitation.id, + }) + return invitation + }) + }, + + async revokeInvitation(sessionToken, invitationId) { + const current = await requireFreshAdministrator(dependencies, sessionToken) + await dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + if (!(await records.revokeInvitation(invitationId, dependencies.now()))) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation is no longer available.", + ) + } + await records.audit({ + event: "membership.invitation.revoke", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + invitationId, + }) + }) + }, + + async changeMembership(sessionToken, membershipId, input) { + const parsed = membershipChangeSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_membership_change", + "The membership change is invalid.", + ) + } + const current = await requireFreshAdministrator(dependencies, sessionToken) + return dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + const target = await records.membershipById(membershipId) + if (!target) { + throw new AuthenticationError( + 404, + "membership_not_found", + "The membership was not found.", + ) + } + if (target.status === "removed") { + throw new AuthenticationError( + 409, + "membership_removed", + "Removed memberships require a new invitation.", + ) + } + const nextRole = parsed.data.role ?? target.role + const nextStatus = parsed.data.status ?? target.status + if (nextRole === target.role && nextStatus === target.status) { + throw new AuthenticationError( + 409, + "membership_unchanged", + "The membership already has this role and status.", + ) + } + const removesAdministrator = + target.status === "active" && + target.role === "administrator" && + (nextStatus !== "active" || nextRole !== "administrator") + if (removesAdministrator && (await records.activeAdministratorCount()) <= 1) { + throw new AuthenticationError( + 409, + "last_administrator", + "The final active administrator cannot be changed.", + ) + } + const changed = await records.saveMembership({ + userId: target.userId, + role: nextRole, + status: nextStatus, + source: target.source, + suspendedAt: nextStatus === "suspended" ? dependencies.now() : null, + removedAt: nextStatus === "removed" ? dependencies.now() : null, + }) + if (nextStatus !== "active") { + await records.deleteUserSessions(target.userId) + } + await records.audit({ + event: "membership.change", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + targetMembershipId: target.id, + context: { + reason: nextStatus !== target.status ? `status_${nextStatus}` : `role_${nextRole}`, + }, + }) + return changed + }) + }, + } +} diff --git a/hub/server/domains/administration/create-service/types.ts b/hub/server/domains/administration/create-service/types.ts new file mode 100644 index 00000000..967f8b77 --- /dev/null +++ b/hub/server/domains/administration/create-service/types.ts @@ -0,0 +1,22 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" +import type { MembershipInvitationRecord } from "#server/repositories/membership-invitations/types" + +export interface AdministrationService { + list(sessionToken: string): Promise<{ + memberships: unknown[] + invitations: MembershipInvitationRecord[] + }> + invite(sessionToken: string, input: unknown): Promise + revokeInvitation(sessionToken: string, invitationId: string): Promise + changeMembership( + sessionToken: string, + membershipId: string, + input: unknown, + ): Promise +} + +export interface AdministrationDependencies { + store: IdentityStore + now: () => Date +} diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index b938c2e6..a6e6a4e5 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -38,15 +38,26 @@ export async function completeGitHubAuthorization( "The authorization request is invalid or expired.", ) } + if (attempt.purpose === "link-identity") { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid.", + ) + } + const purpose: AuthorizationPurpose = attempt.purpose let profile: GitHubUserProfile - let admitted: boolean + let policyAdmitted = false try { profile = await github.identify(code, attempt.pkceVerifier) - admitted = attempt.purpose === "verify" ? true : await admissionPolicy.admit(profile) + if (attempt.purpose === "sign-in") { + policyAdmitted = await admissionPolicy.admit(profile) + } } catch (error) { await store.lock((records) => records.audit({ event: "authentication.provider_failed", + outcome: "failed", actorUserId: null, sessionId: attempt.originatingSessionId, authenticationProviderRevisionId: providerRevisionId, @@ -59,18 +70,7 @@ export async function completeGitHubAuthorization( "The authentication provider failed.", ) } - if (!admitted) { - await store.lock((records) => - records.audit({ - event: "authentication.admission_denied", - actorUserId: null, - sessionId: null, - authenticationProviderRevisionId: providerRevisionId, - }), - ) - throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") - } - return store.lock(async (records) => { + const result = await store.lock(async (records) => { const [currentProvider, stateRecord] = await Promise.all([ records.currentAuthenticationProviderRevision("github"), records.identityStoreState(), @@ -85,7 +85,7 @@ export async function completeGitHubAuthorization( "The authorization request was superseded.", ) } - if (attempt.purpose === "verify") { + if (purpose === "verify") { const session = attempt.originatingSessionId ? await records.sessionById(attempt.originatingSessionId) : null @@ -100,13 +100,64 @@ export async function completeGitHubAuthorization( await records.verifySession(session.id, now()) await records.audit({ event: "session.verify", + outcome: "succeeded", actorUserId: session.userId, sessionId: session.id, authenticationProviderRevisionId: providerRevisionId, }) - return { purpose: "verify" } + return { purpose: "verify" as const } } let identity = await records.externalIdentity("github", configuration.issuer, profile.subject) + let membership = identity ? await records.membership(identity.userId) : null + const invitation = + purpose === "sign-in" && + membership?.status !== "active" && + membership?.status !== "suspended" && + !policyAdmitted + ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) + : null + if ( + purpose === "sign-in" && + membership?.status !== "active" && + !invitation && + !(policyAdmitted && !membership) + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: membership?.status ?? "unassigned" }, + }) + return { denied: true as const } + } + if (purpose === "bootstrap") { + const activeAdministrators = await records.activeAdministratorCount() + const completed = activeAdministrators === 0 && (await records.completeBootstrap(now())) + if (!completed) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason: "unavailable" }, + }) + return { denied: true as const } + } + } else if (purpose === "recovery" && (await records.activeAdministratorCount()) > 0) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason: "active_administrator_exists" }, + }) + return { denied: true as const } + } if (!identity) { const user = await records.createUser({ displayName: profile.displayName ?? profile.username, @@ -136,6 +187,84 @@ export async function completeGitHubAuthorization( avatarUrl: profile.avatarUrl, }) } + membership = await records.membership(identity.userId) + if (purpose === "bootstrap") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "administrator", + source: "bootstrap", + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: "membership.bootstrap", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + }) + } else if (purpose === "recovery") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "administrator", + source: "recovery", + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: "membership.recovery", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + }) + } else if (!membership || membership.status !== "active") { + if (invitation && membership?.status !== "suspended") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: invitation.role, + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + const consumed = await records.consumeInvitation( + invitation.id, + identity.id, + membership.id, + now(), + ) + if (!consumed) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation changed. Start again.", + ) + } + await records.audit({ + event: "membership.invitation.activate", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + invitationId: invitation.id, + }) + } else if (policyAdmitted && !membership) { + await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + } + } const sessionToken = randomBytes(32).toString("base64url") const session = await records.createSession({ userId: identity.userId, @@ -147,10 +276,15 @@ export async function completeGitHubAuthorization( }) await records.audit({ event: "session.sign_in", + outcome: "succeeded", actorUserId: identity.userId, sessionId: session.id, authenticationProviderRevisionId: providerRevisionId, }) - return { sessionToken, purpose: "sign-in" } + return { sessionToken, purpose, denied: false as const } }) + if ("denied" in result && result.denied) { + throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + } + return result } diff --git a/hub/server/domains/authentication/create-admission-policy/index.ts b/hub/server/domains/authentication/create-admission-policy/index.ts index ad4059a7..309e3acb 100644 --- a/hub/server/domains/authentication/create-admission-policy/index.ts +++ b/hub/server/domains/authentication/create-admission-policy/index.ts @@ -10,7 +10,7 @@ export function createAdmissionPolicy( ): AdmissionPolicy { const selected = environment.HUB_ADMISSION_POLICY ?? "hub-managed" if (selected === "hub-managed") { - return { name: "hub-managed", admit: () => Promise.resolve(true) } + return { name: "hub-managed", admit: () => Promise.resolve(false) } } if (selected !== "legacy-repository") { throw new Error("HUB_ADMISSION_POLICY must be hub-managed or legacy-repository.") diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 63be7add..cd6a7d1b 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -1,4 +1,5 @@ import { createAdmissionPolicy } from "#server/domains/authentication/create-admission-policy" +import { createAdministrationService } from "#server/domains/administration/create-service" import { createAuthenticationService } from "#server/domains/authentication/create-service" import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" @@ -51,6 +52,15 @@ function readGitHubAuthentication( } } +function readHostSecret(environment: NodeJS.ProcessEnv, key: string): string | null { + const value = environment[key] + if (!value) return null + if (value.length < 32 || value.length > 1_024) { + throw new Error(`${key} must contain between 32 and 1024 characters.`) + } + return value +} + export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv) { const origin = readOrigin(environment) const configuration = readGitHubAuthentication(environment, origin) @@ -59,6 +69,11 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv ? await ensureGitHubProviderRevision(store, configuration) : null const admissionPolicy = createAdmissionPolicy(environment, store) + const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") + const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") + if (bootstrapSecret && recoverySecret && bootstrapSecret === recoverySecret) { + throw new Error("HUB_BOOTSTRAP_SECRET and HUB_ADMIN_RECOVERY_SECRET must be different.") + } const settings: AuthenticationSettings = { origin: origin.origin, secureCookies: origin.protocol === "https:", @@ -67,12 +82,15 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv trustedProxyHops: readTrustedProxyHops(environment), } return { + administration: createAdministrationService({ store }), service: createAuthenticationService({ store, github: configuration ? createGitHubUserAuthClient(configuration) : null, configuration, providerRevisionId: providerRevision?.id ?? null, admissionPolicy, + bootstrapSecret, + recoverySecret, }), settings, } diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index 55555c8b..e49d9247 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -48,6 +48,20 @@ function records(overrides: Partial = {}) { session: vi.fn().mockResolvedValue(null), sessionById: vi.fn().mockResolvedValue(null), membership: vi.fn().mockResolvedValue(null), + usableInvitation: vi.fn().mockResolvedValue(null), + consumeInvitation: vi.fn().mockResolvedValue(true), + bootstrapCompletedAt: vi.fn().mockResolvedValue(null), + activeAdministratorCount: vi.fn().mockResolvedValue(0), + completeBootstrap: vi.fn().mockResolvedValue(true), + saveMembership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }), audit: vi.fn(), } return Object.assign(fixture, overrides) @@ -108,7 +122,12 @@ function attempt(overrides: Record = {}) { describe("createAuthenticationService()", () => { it("starts a short-lived browser-bound authorization with state and an S256-ready verifier", async () => { const identityRecords = records() - const options = dependencies(identityRecords) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + }) const service = createAuthenticationService(options) const result = await service.start("sign-in") @@ -202,6 +221,7 @@ describe("createAuthenticationService()", () => { freshAuthorizedAt: now, }), }) + const options = dependencies(identityRecords) options.github.identify.mockResolvedValue({ subject: "42", @@ -229,6 +249,126 @@ describe("createAuthenticationService()", () => { ) }) + it("activates only an exact provider identity invitation before issuing a session", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "administrator" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + usableInvitation: vi.fn().mockResolvedValue(invitation), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + saveMembership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "administrator", + source: "invitation", + suspendedAt: null, + removedAt: null, + }), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + const result = await createAuthenticationService(options).complete( + "code", + "state", + "browser-token", + ) + expect(result.purpose).toBe("sign-in") + expect(typeof result.sessionToken).toBe("string") + + expect(identityRecords.usableInvitation).toHaveBeenCalledWith( + "github", + configuration.issuer, + "42", + now, + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + }) + + it("binds bootstrap enrollment to the configured one-use secret", async () => { + const identityRecords = records() + const service = createAuthenticationService({ + ...dependencies(identityRecords), + bootstrapSecret: "bootstrap-secret", + }) + + await expect(service.start("bootstrap", undefined, "wrong-secret")).rejects.toMatchObject({ + status: 404, + code: "bootstrap_unavailable", + }) + const result = await service.start("bootstrap", undefined, "bootstrap-secret") + expect(result.authorizationUrl).toContain("state=") + expect(typeof result.browserToken).toBe("string") + const [createdAttempt] = identityRecords.createAuthorizationAttempt.mock.calls[0]! + expect(createdAttempt.purpose).toBe("bootstrap") + }) + + it("keeps recovery separate and unavailable while an active administrator remains", async () => { + const identityRecords = records({ + activeAdministratorCount: vi.fn().mockResolvedValue(1), + }) + const service = createAuthenticationService({ + ...dependencies(identityRecords), + recoverySecret: "recovery-secret", + }) + + await expect(service.start("recovery", undefined, "recovery-secret")).rejects.toMatchObject({ + status: 404, + code: "recovery_unavailable", + }) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + it("lets a compatibility admission policy deny sign-in before creating identity records", async () => { const identityRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), diff --git a/hub/server/domains/authentication/create-service/membership.test.ts b/hub/server/domains/authentication/create-service/membership.test.ts new file mode 100644 index 00000000..7f0f942e --- /dev/null +++ b/hub/server/domains/authentication/create-service/membership.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import { digestSecret } from "#server/utils/digest-secret" + +const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: "revision", + tokenDigest: digestSecret("session-token"), + signedInAt: new Date("2026-10-27T18:00:00.000Z"), + freshAuthorizedAt: new Date("2026-10-27T18:00:00.000Z"), + user: { id: "user", displayName: "Member", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "member", + displayName: "Member", + avatarUrl: null, + }, + authenticationProviderRevision: { + id: "revision", + authenticationProviderId: "provider", + revision: 1, + issuer: "https://github.com", + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, + }, +} + +describe("createAuthenticationService() membership enforcement", () => { + it.each(["suspended", "removed"] as const)( + "fails closed when a valid session belongs to a %s membership", + async (status) => { + const records = { + session: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: session.userId, + status, + role: "member", + source: "invitation", + suspendedAt: status === "suspended" ? new Date() : null, + removedAt: status === "removed" ? new Date() : null, + }), + } as unknown as IdentityRecords + const store: IdentityStore = { + read: records, + lock: (operation) => operation(records), + } + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { + name: "hub-managed", + admit: () => Promise.resolve(false), + }, + }) + + await expect(service.current("session-token")).rejects.toMatchObject({ + status: 401, + code: "membership_inactive", + }) + }, + ) +}) diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts index 19700b9c..cfd6b3c6 100644 --- a/hub/server/domains/authentication/create-service/types.ts +++ b/hub/server/domains/authentication/create-service/types.ts @@ -6,7 +6,7 @@ import type { } from "#server/integrations/github/create-user-auth-client/types" import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" -export type AuthorizationPurpose = "sign-in" | "verify" +export type AuthorizationPurpose = "sign-in" | "verify" | "bootstrap" | "recovery" export interface AdmissionPolicy { name: "hub-managed" | "legacy-repository" @@ -22,13 +22,14 @@ export interface AuthenticationSettings { } export interface AuthenticationContext extends SessionContext { - membership: InstallationMembershipRecord | null + membership: InstallationMembershipRecord } export interface AuthenticationService { start( purpose: AuthorizationPurpose, sessionToken?: string, + enrollmentSecret?: string, ): Promise<{ authorizationUrl: string; browserToken: string }> complete( code: string, @@ -46,5 +47,7 @@ export interface AuthenticationDependencies { configuration: GitHubUserAuthConfiguration | null providerRevisionId: string | null admissionPolicy: AdmissionPolicy + bootstrapSecret?: string | null + recoverySecret?: string | null now: () => Date } diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts index 933104a6..e8a071d6 100644 --- a/hub/server/domains/authentication/get-current-session/index.ts +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -13,5 +13,8 @@ export async function getCurrentSession( throw new AuthenticationError(401, "sign_in_required", "Sign in to continue.") } const membership = await records.membership(session.userId) + if (!membership || membership.status !== "active") { + throw new AuthenticationError(401, "membership_inactive", "Sign in to continue.") + } return { ...session, membership } } diff --git a/hub/server/domains/authentication/revoke-sessions/index.ts b/hub/server/domains/authentication/revoke-sessions/index.ts index 1488055e..fd6e8b2e 100644 --- a/hub/server/domains/authentication/revoke-sessions/index.ts +++ b/hub/server/domains/authentication/revoke-sessions/index.ts @@ -31,6 +31,7 @@ export async function revokeSessions( await records.deleteSessions() await records.audit({ event: "session.revoke_all", + outcome: "succeeded", actorUserId: current.userId, sessionId: current.id, authenticationProviderRevisionId: current.authenticationProviderRevisionId, diff --git a/hub/server/domains/authentication/sign-out/index.ts b/hub/server/domains/authentication/sign-out/index.ts index dc4cbc4e..0f8160fa 100644 --- a/hub/server/domains/authentication/sign-out/index.ts +++ b/hub/server/domains/authentication/sign-out/index.ts @@ -10,6 +10,7 @@ export async function signOut( await records.deleteSession(current.id) await records.audit({ event: "session.sign_out", + outcome: "succeeded", actorUserId: current.userId, sessionId: current.id, authenticationProviderRevisionId: current.authenticationProviderRevisionId, diff --git a/hub/server/domains/authentication/start-github-authorization/index.ts b/hub/server/domains/authentication/start-github-authorization/index.ts index afde2351..7836278e 100644 --- a/hub/server/domains/authentication/start-github-authorization/index.ts +++ b/hub/server/domains/authentication/start-github-authorization/index.ts @@ -7,13 +7,16 @@ import type { import { getCurrentSession } from "#server/domains/authentication/get-current-session" import { AuthenticationError } from "#server/utils/authentication-error" import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" export async function startGitHubAuthorization( dependencies: AuthenticationDependencies, purpose: AuthorizationPurpose, sessionToken?: string, + enrollmentSecret?: string, ) { - const { configuration, github, now, providerRevisionId, store } = dependencies + const { bootstrapSecret, configuration, github, now, providerRevisionId, recoverySecret, store } = + dependencies if (!configuration || !github || !providerRevisionId) { throw new AuthenticationError( 503, @@ -38,6 +41,26 @@ export async function startGitHubAuthorization( ? await getCurrentSession(dependencies, sessionToken ?? "", records) : null const stateRecord = await records.identityStoreState() + if (purpose === "bootstrap") { + if ( + (await records.bootstrapCompletedAt()) || + !bootstrapSecret || + !enrollmentSecret || + !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) + ) { + throw new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") + } + } + if (purpose === "recovery") { + if ( + !recoverySecret || + !enrollmentSecret || + !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) || + (await records.activeAdministratorCount()) > 0 + ) { + throw new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") + } + } await records.createAuthorizationAttempt({ authenticationProviderRevisionId: providerRevisionId, originatingSessionId: session?.id ?? null, diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 451ae87d..1c3676b5 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -9,14 +9,27 @@ import { advanceGitHubIntegrationGeneration } from "#server/repositories/github- import { getGitHubIntegrationState } from "#server/repositories/github-integration-state/get-state" import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-state/save-credentials" import { advanceSessionGeneration } from "#server/repositories/identity-store-state/advance-session-generation" +import { completeBootstrap } from "#server/repositories/identity-store-state/complete-bootstrap" +import { getBootstrapCompletedAt } from "#server/repositories/identity-store-state/get-bootstrap-completed-at" import { getIdentityStoreState } from "#server/repositories/identity-store-state/get-state" +import { countActiveAdministrators } from "#server/repositories/installation-memberships/count-active-administrators" import { getInstallationMembership } from "#server/repositories/installation-memberships/get-membership" +import { getInstallationMembershipById } from "#server/repositories/installation-memberships/get-membership-by-id" +import { getInstallationMemberships } from "#server/repositories/installation-memberships/get-memberships" import { saveInstallationMembership } from "#server/repositories/installation-memberships/save-membership" +import { consumeMembershipInvitation } from "#server/repositories/membership-invitations/consume-invitation" +import { createMembershipInvitation } from "#server/repositories/membership-invitations/create-invitation" +import { getMembershipInvitation } from "#server/repositories/membership-invitations/get-invitation" +import { getMembershipInvitations } from "#server/repositories/membership-invitations/get-invitations" +import { getUsableMembershipInvitation } from "#server/repositories/membership-invitations/get-usable-invitation" +import { revokeMembershipInvitation } from "#server/repositories/membership-invitations/revoke-invitation" +import { revokeExpiredMembershipInvitations } from "#server/repositories/membership-invitations/revoke-expired-invitations" 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 { deleteUserSessions } from "#server/repositories/sessions/delete-user-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" @@ -31,7 +44,9 @@ import type { Prisma } from "../../../generated/prisma/client.ts" export function createIdentityRecords(database: Prisma.TransactionClient): IdentityRecords { return { identityStoreState: getIdentityStoreState.bind(null, database), + bootstrapCompletedAt: getBootstrapCompletedAt.bind(null, database), advanceSessionGeneration: advanceSessionGeneration.bind(null, database), + completeBootstrap: completeBootstrap.bind(null, database), githubIntegrationState: getGitHubIntegrationState.bind(null, database), saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), advanceGitHubIntegrationGeneration: advanceGitHubIntegrationGeneration.bind(null, database), @@ -41,7 +56,17 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident externalIdentity: getExternalIdentity.bind(null, database), saveExternalIdentity: saveExternalIdentity.bind(null, database), membership: getInstallationMembership.bind(null, database), + membershipById: getInstallationMembershipById.bind(null, database), + memberships: getInstallationMemberships.bind(null, database), + activeAdministratorCount: countActiveAdministrators.bind(null, database), saveMembership: saveInstallationMembership.bind(null, database), + createInvitation: createMembershipInvitation.bind(null, database), + invitation: getMembershipInvitation.bind(null, database), + invitations: getMembershipInvitations.bind(null, database), + usableInvitation: getUsableMembershipInvitation.bind(null, database), + consumeInvitation: consumeMembershipInvitation.bind(null, database), + revokeInvitation: revokeMembershipInvitation.bind(null, database), + revokeExpiredInvitations: revokeExpiredMembershipInvitations.bind(null, database), createAuthenticationProviderRevision: createAuthenticationProviderRevision.bind(null, database), currentAuthenticationProviderRevision: getCurrentAuthenticationProviderRevision.bind( null, @@ -55,6 +80,7 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident invalidateFreshness: invalidateFreshness.bind(null, database), deleteSession: deleteSession.bind(null, database), deleteSessions: deleteSessions.bind(null, database), + deleteUserSessions: deleteUserSessions.bind(null, database), createAuthorizationAttempt: createAuthorizationAttempt.bind(null, database), consumeAuthorizationAttempt: consumeAuthorizationAttempt.bind(null, database), audit: saveSecurityAudit.bind(null, database), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 7ffb9d19..d21f7827 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -6,10 +6,15 @@ import type { } from "#server/repositories/external-identities/types" import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" import type { + InstallationMembershipDetails, InstallationMembershipInput, InstallationMembershipRecord, } from "#server/repositories/installation-memberships/types" -import type { AuditInput } from "#server/repositories/security-audits/types" +import type { + MembershipInvitationInput, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" +import type { AuditInput, SecurityAuditRecord } from "#server/repositories/security-audits/types" import type { HubSession } from "#server/repositories/sessions/types" import type { HubUser, UserInput } from "#server/repositories/users/types" import type { IdentityStoreState } from "#server/repositories/identity-store-state/types" @@ -36,7 +41,9 @@ export interface SessionContext extends HubSession { export interface IdentityRecords { identityStoreState(): Promise + bootstrapCompletedAt(): Promise advanceSessionGeneration(): Promise + completeBootstrap(completedAt: Date): Promise githubIntegrationState(): Promise saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise advanceGitHubIntegrationGeneration(): Promise @@ -52,7 +59,27 @@ export interface IdentityRecords { input: ExternalIdentityInput, ): Promise membership(userId: string): Promise + membershipById(id: string): Promise + memberships(): Promise + activeAdministratorCount(): Promise saveMembership(input: InstallationMembershipInput): Promise + createInvitation(input: MembershipInvitationInput): Promise + invitation(id: string): Promise + invitations(): Promise + usableInvitation( + provider: string, + issuer: string, + subject: string, + now: Date, + ): Promise + consumeInvitation( + id: string, + activatedIdentityId: string, + activatedMembershipId: string, + consumedAt: Date, + ): Promise + revokeInvitation(id: string, revokedAt: Date): Promise + revokeExpiredInvitations(revokedAt: Date): Promise createAuthenticationProviderRevision( input: AuthenticationProviderInput, ): Promise @@ -67,6 +94,7 @@ export interface IdentityRecords { invalidateFreshness(): Promise deleteSession(id: string): Promise deleteSessions(): Promise + deleteUserSessions(userId: string): Promise createAuthorizationAttempt( input: Omit & { sessionGeneration?: number @@ -74,7 +102,7 @@ export interface IdentityRecords { ): Promise consumeAuthorizationAttempt(stateDigest: string): Promise audit(input: AuditInput): Promise - audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> + audits(): Promise } export interface IdentityStore { diff --git a/hub/server/integrations/express/respond-administration-error/index.ts b/hub/server/integrations/express/respond-administration-error/index.ts new file mode 100644 index 00000000..f3ddf3ff --- /dev/null +++ b/hub/server/integrations/express/respond-administration-error/index.ts @@ -0,0 +1,20 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function respondAdministrationError( + error: unknown, + _request: Request, + response: Response, + next: NextFunction, +): void { + if (!(error instanceof AuthenticationError)) { + next(error) + return + } + if (error.code === "csrf_failed" || error.code === "origin_failed") { + response.status(403).json({ error: "Request not allowed" }) + return + } + response.status(error.status).json({ error: error.code, message: error.message }) +} diff --git a/hub/server/repositories/authorization-attempts/types.ts b/hub/server/repositories/authorization-attempts/types.ts index 54e18768..9bad2393 100644 --- a/hub/server/repositories/authorization-attempts/types.ts +++ b/hub/server/repositories/authorization-attempts/types.ts @@ -6,7 +6,7 @@ export interface AuthorizationAttempt { browserDigest: string pkceVerifier: string nonce: string | null - purpose: "sign-in" | "verify" | "link-identity" + purpose: "sign-in" | "verify" | "link-identity" | "bootstrap" | "recovery" sessionGeneration: number expiresAt: Date } diff --git a/hub/server/repositories/identity-store-state/complete-bootstrap/index.ts b/hub/server/repositories/identity-store-state/complete-bootstrap/index.ts new file mode 100644 index 00000000..c8a1a35e --- /dev/null +++ b/hub/server/repositories/identity-store-state/complete-bootstrap/index.ts @@ -0,0 +1,14 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +import { staticIdentityStoreRecordId } from "../constants.ts" + +export async function completeBootstrap( + database: Prisma.TransactionClient, + completedAt: Date, +): Promise { + const updated = await database.identityStoreState.updateMany({ + where: { id: staticIdentityStoreRecordId, bootstrapCompletedAt: null }, + data: { bootstrapCompletedAt: completedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts b/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts new file mode 100644 index 00000000..eae2e392 --- /dev/null +++ b/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts @@ -0,0 +1,11 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getBootstrapCompletedAt(database: Prisma.TransactionClient) { + const state = await database.identityStoreState.findUniqueOrThrow({ + where: { id: staticIdentityStoreRecordId }, + select: { bootstrapCompletedAt: true }, + }) + return state.bootstrapCompletedAt +} diff --git a/hub/server/repositories/installation-memberships/count-active-administrators/index.ts b/hub/server/repositories/installation-memberships/count-active-administrators/index.ts new file mode 100644 index 00000000..877e61a5 --- /dev/null +++ b/hub/server/repositories/installation-memberships/count-active-administrators/index.ts @@ -0,0 +1,7 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function countActiveAdministrators(database: Prisma.TransactionClient) { + return database.installationMembership.count({ + where: { status: "active", role: "administrator" }, + }) +} diff --git a/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts b/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts new file mode 100644 index 00000000..82885058 --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipDetails } from "../types.ts" + +export async function getInstallationMembershipById( + database: Prisma.TransactionClient, + id: string, +): Promise { + return (await database.installationMembership.findUnique({ + where: { id }, + include: { user: { include: { externalIdentities: true } } }, + })) as InstallationMembershipDetails | null +} diff --git a/hub/server/repositories/installation-memberships/get-membership/index.ts b/hub/server/repositories/installation-memberships/get-membership/index.ts index 167de2c0..f79ddec6 100644 --- a/hub/server/repositories/installation-memberships/get-membership/index.ts +++ b/hub/server/repositories/installation-memberships/get-membership/index.ts @@ -1,5 +1,11 @@ import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipRecord } from "../types.ts" -export function getInstallationMembership(database: Prisma.TransactionClient, userId: string) { - return database.installationMembership.findUnique({ where: { userId } }) +export async function getInstallationMembership( + database: Prisma.TransactionClient, + userId: string, +): Promise { + return (await database.installationMembership.findUnique({ + where: { userId }, + })) as InstallationMembershipRecord | null } diff --git a/hub/server/repositories/installation-memberships/get-memberships/index.ts b/hub/server/repositories/installation-memberships/get-memberships/index.ts new file mode 100644 index 00000000..7d3580ae --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-memberships/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipDetails } from "../types.ts" + +export async function getInstallationMemberships( + database: Prisma.TransactionClient, +): Promise { + return (await database.installationMembership.findMany({ + include: { user: { include: { externalIdentities: true } } }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 100, + })) as InstallationMembershipDetails[] +} diff --git a/hub/server/repositories/installation-memberships/save-membership/index.ts b/hub/server/repositories/installation-memberships/save-membership/index.ts index f38357c9..930b75ae 100644 --- a/hub/server/repositories/installation-memberships/save-membership/index.ts +++ b/hub/server/repositories/installation-memberships/save-membership/index.ts @@ -1,15 +1,18 @@ -import type { InstallationMembershipInput } from "#server/repositories/installation-memberships/types" +import type { + InstallationMembershipInput, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" import type { Prisma } from "../../../generated/prisma/client.ts" -export function saveInstallationMembership( +export async function saveInstallationMembership( database: Prisma.TransactionClient, input: InstallationMembershipInput, -) { +): Promise { const { userId, ...membership } = input - return database.installationMembership.upsert({ + return (await database.installationMembership.upsert({ where: { userId }, create: input, update: membership, - }) + })) as InstallationMembershipRecord } diff --git a/hub/server/repositories/installation-memberships/types.ts b/hub/server/repositories/installation-memberships/types.ts index 41d75837..5823ef23 100644 --- a/hub/server/repositories/installation-memberships/types.ts +++ b/hub/server/repositories/installation-memberships/types.ts @@ -1,14 +1,24 @@ export interface InstallationMembershipInput { userId: string - status: "active" | "suspended" + status: "active" | "suspended" | "removed" role: "member" | "administrator" - source: "bootstrap" | "manual" | "synchronized" + source: "bootstrap" | "invitation" | "manual" | "recovery" | "synchronized" + suspendedAt?: Date | null + removedAt?: Date | null } export interface InstallationMembershipRecord { id: string userId: string - status: string - role: string - source: string + status: InstallationMembershipInput["status"] + role: InstallationMembershipInput["role"] + source: InstallationMembershipInput["source"] + suspendedAt: Date | null + removedAt: Date | null } + +export interface InstallationMembershipDetails extends InstallationMembershipRecord { + user: HubUser & { externalIdentities: HubExternalIdentity[] } +} +import type { HubExternalIdentity } from "#server/repositories/external-identities/types" +import type { HubUser } from "#server/repositories/users/types" diff --git a/hub/server/repositories/membership-invitations/consume-invitation/index.ts b/hub/server/repositories/membership-invitations/consume-invitation/index.ts new file mode 100644 index 00000000..ba1b2194 --- /dev/null +++ b/hub/server/repositories/membership-invitations/consume-invitation/index.ts @@ -0,0 +1,15 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function consumeMembershipInvitation( + database: Prisma.TransactionClient, + id: string, + activatedIdentityId: string, + activatedMembershipId: string, + consumedAt: Date, +): Promise { + const updated = await database.membershipInvitation.updateMany({ + where: { id, consumedAt: null, revokedAt: null, expiresAt: { gt: consumedAt } }, + data: { activatedIdentityId, activatedMembershipId, consumedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/membership-invitations/create-invitation/index.ts b/hub/server/repositories/membership-invitations/create-invitation/index.ts new file mode 100644 index 00000000..dd721b6b --- /dev/null +++ b/hub/server/repositories/membership-invitations/create-invitation/index.ts @@ -0,0 +1,15 @@ +import type { + MembershipInvitationInput, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createMembershipInvitation( + database: Prisma.TransactionClient, + input: MembershipInvitationInput, +): Promise { + return (await database.membershipInvitation.create({ + data: input, + })) as MembershipInvitationRecord +} diff --git a/hub/server/repositories/membership-invitations/get-invitation/index.ts b/hub/server/repositories/membership-invitations/get-invitation/index.ts new file mode 100644 index 00000000..d4d3d130 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitation/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitation( + database: Prisma.TransactionClient, + id: string, +): Promise { + return (await database.membershipInvitation.findUnique({ + where: { id }, + })) as MembershipInvitationRecord | null +} diff --git a/hub/server/repositories/membership-invitations/get-invitations/index.ts b/hub/server/repositories/membership-invitations/get-invitations/index.ts new file mode 100644 index 00000000..e835e410 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitations/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitations( + database: Prisma.TransactionClient, +): Promise { + return (await database.membershipInvitation.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 100, + })) as MembershipInvitationRecord[] +} diff --git a/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts b/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts new file mode 100644 index 00000000..9c7dfed6 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts @@ -0,0 +1,22 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getUsableMembershipInvitation( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + subject: string, + now: Date, +): Promise { + return (await database.membershipInvitation.findFirst({ + where: { + provider, + issuer, + subject, + consumedAt: null, + revokedAt: null, + expiresAt: { gt: now }, + }, + orderBy: { createdAt: "asc" }, + })) as MembershipInvitationRecord | null +} diff --git a/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts b/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts new file mode 100644 index 00000000..ba501bfc --- /dev/null +++ b/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function revokeExpiredMembershipInvitations( + database: Prisma.TransactionClient, + revokedAt: Date, +) { + await database.membershipInvitation.updateMany({ + where: { consumedAt: null, revokedAt: null, expiresAt: { lte: revokedAt } }, + data: { revokedAt }, + }) +} diff --git a/hub/server/repositories/membership-invitations/revoke-invitation/index.ts b/hub/server/repositories/membership-invitations/revoke-invitation/index.ts new file mode 100644 index 00000000..563e8665 --- /dev/null +++ b/hub/server/repositories/membership-invitations/revoke-invitation/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function revokeMembershipInvitation( + database: Prisma.TransactionClient, + id: string, + revokedAt: Date, +): Promise { + const updated = await database.membershipInvitation.updateMany({ + where: { id, consumedAt: null, revokedAt: null }, + data: { revokedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/membership-invitations/types.ts b/hub/server/repositories/membership-invitations/types.ts new file mode 100644 index 00000000..86401ab7 --- /dev/null +++ b/hub/server/repositories/membership-invitations/types.ts @@ -0,0 +1,17 @@ +export interface MembershipInvitationInput { + createdByUserId: string | null + provider: string + issuer: string + subject: string + role: "member" | "administrator" + expiresAt: Date +} + +export interface MembershipInvitationRecord extends MembershipInvitationInput { + id: string + activatedIdentityId: string | null + activatedMembershipId: string | null + consumedAt: Date | null + revokedAt: Date | null + createdAt: Date +} diff --git a/hub/server/repositories/security-audits/get-audits/index.ts b/hub/server/repositories/security-audits/get-audits/index.ts index 1b4a2e2c..6783bc47 100644 --- a/hub/server/repositories/security-audits/get-audits/index.ts +++ b/hub/server/repositories/security-audits/get-audits/index.ts @@ -1,8 +1,11 @@ import { auditResultLimit, auditRetentionMs } from "#server/repositories/security-audits/constants" +import type { SecurityAuditRecord } from "#server/repositories/security-audits/types" import type { Prisma } from "../../../generated/prisma/client.ts" -export function getSecurityAudits(database: Prisma.TransactionClient) { +export async function getSecurityAudits( + database: Prisma.TransactionClient, +): Promise { return database.securityAudit.findMany({ where: { createdAt: { gte: new Date(Date.now() - auditRetentionMs) } }, orderBy: { createdAt: "desc" }, diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts index df32fdd1..b67c6235 100644 --- a/hub/server/repositories/security-audits/save-audit/index.ts +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -4,8 +4,27 @@ 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) { + const context = data.context ?? {} + const validContext = + Object.keys(context).every((key) => key === "reason" || key === "source") && + Object.values(context).every((value) => /^[a-z][a-z0-9_]{0,63}$/.test(value)) + if ( + !/^[a-z][a-z0-9_.]{0,79}$/.test(data.event) || + !["succeeded", "denied", "failed"].includes(data.outcome ?? "succeeded") || + !validContext + ) { + throw new Error("Security audit input is invalid.") + } await database.securityAudit.deleteMany({ where: { createdAt: { lt: new Date(Date.now() - auditRetentionMs) } }, }) - await database.securityAudit.create({ data }) + await database.securityAudit.create({ + data: { + ...data, + outcome: data.outcome ?? "succeeded", + targetMembershipId: data.targetMembershipId ?? null, + invitationId: data.invitationId ?? null, + context, + }, + }) } diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index ab72e801..7283d69c 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -1,6 +1,23 @@ export interface AuditInput { event: string + outcome?: "succeeded" | "denied" | "failed" actorUserId: string | null sessionId: string | null authenticationProviderRevisionId: string | null + targetMembershipId?: string | null + invitationId?: string | null + context?: Record +} + +export interface SecurityAuditRecord { + id: string + event: string + outcome: string + actorUserId: string | null + sessionId: string | null + authenticationProviderRevisionId: string | null + targetMembershipId: string | null + invitationId: string | null + context: unknown + createdAt: Date } diff --git a/hub/server/repositories/sessions/delete-user-sessions/index.ts b/hub/server/repositories/sessions/delete-user-sessions/index.ts new file mode 100644 index 00000000..81870fda --- /dev/null +++ b/hub/server/repositories/sessions/delete-user-sessions/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteUserSessions(database: Prisma.TransactionClient, userId: string) { + await database.session.deleteMany({ where: { userId } }) +} diff --git a/hub/server/routes/administration.test.ts b/hub/server/routes/administration.test.ts new file mode 100644 index 00000000..c06ce24d --- /dev/null +++ b/hub/server/routes/administration.test.ts @@ -0,0 +1,154 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" +import { AuthenticationError } from "#server/utils/authentication-error" + +import { createApp } from "../create-app/index.ts" + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const protectedHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "content-type": "application/json", + "x-csrf-token": csrfToken, +} + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { administration: Mocked } { + const service = { + start: vi.fn(), + complete: vi.fn(), + current: vi.fn(), + signOut: vi.fn(), + revokeAll: vi.fn(), + } as unknown as Mocked + const administration = Object.assign( + { + list: vi.fn().mockResolvedValue({ memberships: [], invitations: [] }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + }, + overrides, + ) + return { + service, + administration, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes) { + const server = createServer(createApp({ authentication })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe("administration routes", () => { + it("returns an explicit authorization failure without leaking the session token", async () => { + const authentication = runtime({ + list: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ), + ), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "administrator_required", + message: "Administrator access is required.", + }) + expect(authentication.administration.list.mock.calls).toEqual([[sessionToken]]) + }) + + it("rejects membership changes without matching CSRF before calling the service", async () => { + const authentication = runtime() + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/memberships/membership`, { + method: "PATCH", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + body: JSON.stringify({ status: "suspended" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.administration.changeMembership.mock.calls).toEqual([]) + }) + + it("preserves the last-administrator conflict shape", async () => { + const authentication = runtime({ + changeMembership: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 409, + "last_administrator", + "The final active administrator cannot be changed.", + ), + ), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/memberships/membership`, { + method: "PATCH", + headers: protectedHeaders, + body: JSON.stringify({ role: "member" }), + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: "last_administrator", + message: "The final active administrator cannot be changed.", + }) + }) + + it("delegates invitation revocation with the authenticated session", async () => { + const authentication = runtime({ + revokeInvitation: vi.fn().mockResolvedValue(undefined), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/invitations/invitation`, { + method: "DELETE", + headers: protectedHeaders, + }) + + expect(response.status).toBe(204) + expect(authentication.administration.revokeInvitation.mock.calls).toEqual([ + [sessionToken, "invitation"], + ]) + }) +}) diff --git a/hub/server/routes/api/administration/get/index.ts b/hub/server/routes/api/administration/get/index.ts new file mode 100644 index 00000000..32c85cab --- /dev/null +++ b/hub/server/routes/api/administration/get/index.ts @@ -0,0 +1,16 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + response.json( + await authentication.administration.list(readCookie(request.headers.cookie, cookies.session)), + ) +} diff --git a/hub/server/routes/api/administration/invitations/delete/index.ts b/hub/server/routes/api/administration/invitations/delete/index.ts new file mode 100644 index 00000000..5fc68ebd --- /dev/null +++ b/hub/server/routes/api/administration/invitations/delete/index.ts @@ -0,0 +1,23 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function remove( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.administration.revokeInvitation( + readCookie(request.headers.cookie, cookies.session), + typeof request.params.id === "string" ? request.params.id : "", + ) + response.status(204).end() +} diff --git a/hub/server/routes/api/administration/invitations/post/index.ts b/hub/server/routes/api/administration/invitations/post/index.ts new file mode 100644 index 00000000..0a9b576c --- /dev/null +++ b/hub/server/routes/api/administration/invitations/post/index.ts @@ -0,0 +1,23 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const invitation = await authentication.administration.invite( + readCookie(request.headers.cookie, cookies.session), + request.body, + ) + response.status(201).json({ invitation }) +} diff --git a/hub/server/routes/api/administration/memberships/patch/index.ts b/hub/server/routes/api/administration/memberships/patch/index.ts new file mode 100644 index 00000000..aaa04c8d --- /dev/null +++ b/hub/server/routes/api/administration/memberships/patch/index.ts @@ -0,0 +1,24 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function patch( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const membership = await authentication.administration.changeMembership( + readCookie(request.headers.cookie, cookies.session), + typeof request.params.id === "string" ? request.params.id : "", + request.body, + ) + response.json({ membership }) +} diff --git a/hub/server/routes/api/auth/enrollment/post/index.ts b/hub/server/routes/api/auth/enrollment/post/index.ts new file mode 100644 index 00000000..92795e3f --- /dev/null +++ b/hub/server/routes/api/auth/enrollment/post/index.ts @@ -0,0 +1,33 @@ +import type { Request, Response } from "express" + +import type { AuthorizationPurpose } from "#server/domains/authentication/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { requireCsrfToken } from "#server/utils/require-csrf-token" +import { readCookie } from "#server/utils/read-cookie" + +export async function post( + authentication: AuthenticationRoutes, + purpose: Extract, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const secret = + request.body && + typeof request.body === "object" && + !Array.isArray(request.body) && + typeof (request.body as Record).secret === "string" + ? (request.body as Record).secret + : "" + const attempt = await authentication.service.start(purpose, undefined, secret) + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} diff --git a/hub/server/routes/api/session/get/index.ts b/hub/server/routes/api/session/get/index.ts index 3e6cde1b..9f297743 100644 --- a/hub/server/routes/api/session/get/index.ts +++ b/hub/server/routes/api/session/get/index.ts @@ -37,6 +37,11 @@ export async function get( signedInAt: current.signedInAt, freshAuthorizedAt: current.freshAuthorizedAt, }, + membership: { + id: current.membership.id, + role: current.membership.role, + status: current.membership.status, + }, }) } catch (error) { if (!(error instanceof AuthenticationError) || error.status !== 401) throw error diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 4308650e..5c6f3b2b 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -36,6 +36,12 @@ function authentication( }, overrides, ) + const administration = { + list: vi.fn().mockResolvedValue({ memberships: [], invitations: [] }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + } return { settings: { origin: "https://hub.example", @@ -45,6 +51,7 @@ function authentication( trustedProxyHops, }, service, + administration, } } @@ -340,7 +347,15 @@ describe("authentication routes", () => { enabled: true, }, }, - membership: null, + membership: { + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "invitation", + suspendedAt: null, + removedAt: null, + }, }), }) const baseUrl = await serve(runtime) diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index d1af83ee..9adb4378 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -3,8 +3,14 @@ import type { Express } from "express" import { createRateLimit } from "#server/integrations/express/create-rate-limit" import { prepareAuthenticationRequest } from "#server/integrations/express/prepare-authentication-request" import { respondAuthenticationError } from "#server/integrations/express/respond-authentication-error" +import { respondAdministrationError } from "#server/integrations/express/respond-administration-error" import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" +import { post as handleApiAuthEnrollmentPost } from "#server/routes/api/auth/enrollment/post" +import { get as handleApiAdministrationGet } from "#server/routes/api/administration/get" +import { post as handleApiAdministrationInvitationsPost } from "#server/routes/api/administration/invitations/post" +import { remove as handleApiAdministrationInvitationsDelete } from "#server/routes/api/administration/invitations/delete" +import { patch as handleApiAdministrationMembershipsPatch } from "#server/routes/api/administration/memberships/patch" import { get as handleApiHealthGet } from "./api/health/get/index.ts" import { delete as handleApiSessionDelete } from "#server/routes/api/session/delete" import { get as handleApiSessionGet } from "#server/routes/api/session/get" @@ -43,5 +49,29 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi handleApiSessionVerifyPost.bind(null, authentication), ) app.post("/api/session/revoke-all", handleApiSessionRevokeAllPost.bind(null, authentication)) + app.post( + "/api/auth/bootstrap", + initiationLimit, + handleApiAuthEnrollmentPost.bind(null, authentication, "bootstrap"), + ) + app.post( + "/api/auth/recovery", + initiationLimit, + handleApiAuthEnrollmentPost.bind(null, authentication, "recovery"), + ) + app.get("/api/administration", handleApiAdministrationGet.bind(null, authentication)) + app.post( + "/api/administration/invitations", + handleApiAdministrationInvitationsPost.bind(null, authentication), + ) + app.delete( + "/api/administration/invitations/:id", + handleApiAdministrationInvitationsDelete.bind(null, authentication), + ) + app.patch( + "/api/administration/memberships/:id", + handleApiAdministrationMembershipsPatch.bind(null, authentication), + ) + app.use("/api/administration", respondAdministrationError) app.use(respondAuthenticationError) } diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index eadccf7d..8a775590 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -2,8 +2,10 @@ import type { AuthenticationService, AuthenticationSettings, } from "#server/domains/authentication/create-service/types" +import type { AdministrationService } from "#server/domains/administration/create-service/types" export interface AuthenticationRoutes { service: AuthenticationService + administration: AdministrationService settings: AuthenticationSettings } From c3e12cef3d607088c37aaa465449e2643099dbb3 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 13:52:40 -0700 Subject: [PATCH 10/56] fix(hub): audit enrollment denials Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.test.ts | 54 ++++++++++++++++++ .../start-github-authorization/index.ts | 56 ++++++++++++++----- hub/server/routes/administration.test.ts | 28 ++++++++++ hub/server/routes/index.ts | 2 +- 4 files changed, 124 insertions(+), 16 deletions(-) diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index e49d9247..c29b00f6 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -346,6 +346,14 @@ describe("createAuthenticationService()", () => { status: 404, code: "bootstrap_unavailable", }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "secret_mismatch" }, + }) const result = await service.start("bootstrap", undefined, "bootstrap-secret") expect(result.authorizationUrl).toContain("state=") expect(typeof result.browserToken).toBe("string") @@ -366,6 +374,52 @@ describe("createAuthenticationService()", () => { status: 404, code: "recovery_unavailable", }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "active_administrator_exists" }, + }) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + + it("audits missing recovery proof without recording the submitted request", async () => { + const identityRecords = records() + const service = createAuthenticationService({ + ...dependencies(identityRecords), + recoverySecret: "recovery-secret", + }) + + await expect(service.start("recovery")).rejects.toMatchObject({ + status: 404, + code: "recovery_unavailable", + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "secret_missing" }, + }) + expect(JSON.stringify(identityRecords.audit.mock.calls)).not.toContain("recovery-secret") + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + + it("does not turn a failed denied-audit write into a normal enrollment rejection", async () => { + const identityRecords = records({ + audit: vi.fn().mockRejectedValue(new Error("audit failed")), + }) + const service = createAuthenticationService({ + ...dependencies(identityRecords), + bootstrapSecret: "bootstrap-secret", + }) + + await expect(service.start("bootstrap", undefined, "wrong-secret")).rejects.toThrow( + "audit failed", + ) expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() }) diff --git a/hub/server/domains/authentication/start-github-authorization/index.ts b/hub/server/domains/authentication/start-github-authorization/index.ts index 7836278e..92886b82 100644 --- a/hub/server/domains/authentication/start-github-authorization/index.ts +++ b/hub/server/domains/authentication/start-github-authorization/index.ts @@ -27,7 +27,7 @@ export async function startGitHubAuthorization( const state = randomBytes(32).toString("base64url") const browserToken = randomBytes(32).toString("base64url") const pkceVerifier = randomBytes(32).toString("base64url") - await store.lock(async (records) => { + const denial = await store.lock(async (records) => { const provider = await records.currentAuthenticationProviderRevision("github") if (provider?.id !== providerRevisionId) { throw new AuthenticationError( @@ -42,23 +42,47 @@ export async function startGitHubAuthorization( : null const stateRecord = await records.identityStoreState() if (purpose === "bootstrap") { - if ( - (await records.bootstrapCompletedAt()) || - !bootstrapSecret || - !enrollmentSecret || - !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) - ) { - throw new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") + const reason = (await records.bootstrapCompletedAt()) + ? "already_completed" + : !bootstrapSecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) + ? "secret_mismatch" + : null + if (reason) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") } } if (purpose === "recovery") { - if ( - !recoverySecret || - !enrollmentSecret || - !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) || - (await records.activeAdministratorCount()) > 0 - ) { - throw new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") + const reason = !recoverySecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) + ? "secret_mismatch" + : (await records.activeAdministratorCount()) > 0 + ? "active_administrator_exists" + : null + if (reason) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") } } await records.createAuthorizationAttempt({ @@ -72,7 +96,9 @@ export async function startGitHubAuthorization( sessionGeneration: stateRecord.sessionGeneration, expiresAt: new Date(now().getTime() + 10 * 60_000), }) + return null }) + if (denial) throw denial return { authorizationUrl: github.authorizationUrl(state, pkceVerifier), browserToken, diff --git a/hub/server/routes/administration.test.ts b/hub/server/routes/administration.test.ts index c06ce24d..534d8e09 100644 --- a/hub/server/routes/administration.test.ts +++ b/hub/server/routes/administration.test.ts @@ -91,6 +91,34 @@ describe("administration routes", () => { message: "Administrator access is required.", }) expect(authentication.administration.list.mock.calls).toEqual([[sessionToken]]) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }) + + it.each([ + { name: "missing", origin: undefined }, + { name: "wrong", origin: "https://attacker.example" }, + ])("rejects a $name Origin before an administration mutation runs", async ({ origin }) => { + const authentication = runtime() + const baseUrl = await serve(authentication) + const headers = { ...protectedHeaders } + if (origin) headers.origin = origin + else delete (headers as { origin?: string }).origin + + const response = await fetch(`${baseUrl}/api/administration/invitations`, { + method: "POST", + headers, + body: JSON.stringify({ + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.administration.invite.mock.calls).toEqual([]) }) it("rejects membership changes without matching CSRF before calling the service", async () => { diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 9adb4378..e33dda0c 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -24,7 +24,7 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi if (!authentication) return app.use( - ["/api/auth", "/api/session"], + ["/api/auth", "/api/session", "/api/administration"], prepareAuthenticationRequest.bind(null, authentication.settings.origin), ) const initiationLimit = createRateLimit({ From 474270243e4115d58916a0cbfd8bfa3b627b3f90 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 14:15:03 -0700 Subject: [PATCH 11/56] feat(hub): add prototype authorization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/README.md | 1 + hub/docs/architecture.md | 6 +- hub/docs/database.md | 4 +- hub/docs/identity.md | 4 +- hub/docs/membership-and-administration.md | 4 +- hub/docs/prototype-authorization.md | 71 +++ hub/docs/testing-and-operations.md | 2 + hub/schema/access-effect.enum.prisma | 4 + .../migration.sql | 109 ++++ hub/schema/prototype-access-grant.prisma | 22 + hub/schema/prototype-role.enum.prisma | 6 + hub/schema/prototype-version.prisma | 22 + hub/schema/prototype.prisma | 24 + hub/schema/security-audit.prisma | 8 + hub/schema/user.prisma | 5 + hub/schema/workspace-access-grant.prisma | 22 + hub/schema/workspace.prisma | 20 + hub/server/create-app/index.ts | 5 +- .../authentication/create-runtime/index.ts | 2 + .../create-service/index.db.test.ts | 483 ++++++++++++++ .../create-service/index.ts | 588 ++++++++++++++++++ .../create-service/types.ts | 47 ++ .../evaluate-access/index.test.ts | 78 +++ .../evaluate-access/index.ts | 49 ++ .../evaluate-access/types.ts | 19 + .../express/create-rate-limit/index.ts | 4 +- .../security-audits/save-audit/index.ts | 4 + .../repositories/security-audits/types.ts | 8 + hub/server/routes/administration.test.ts | 2 + hub/server/routes/authentication.test.ts | 2 + hub/server/routes/index.ts | 71 ++- hub/server/routes/product/index.test.ts | 252 ++++++++ hub/server/routes/product/index.ts | 219 +++++++ hub/server/routes/types.ts | 2 + 34 files changed, 2158 insertions(+), 11 deletions(-) create mode 100644 hub/docs/prototype-authorization.md create mode 100644 hub/schema/access-effect.enum.prisma create mode 100644 hub/schema/migrations/20261027220000_prototype_authorization/migration.sql create mode 100644 hub/schema/prototype-access-grant.prisma create mode 100644 hub/schema/prototype-role.enum.prisma create mode 100644 hub/schema/prototype-version.prisma create mode 100644 hub/schema/prototype.prisma create mode 100644 hub/schema/workspace-access-grant.prisma create mode 100644 hub/schema/workspace.prisma create mode 100644 hub/server/domains/prototype-authorization/create-service/index.db.test.ts create mode 100644 hub/server/domains/prototype-authorization/create-service/index.ts create mode 100644 hub/server/domains/prototype-authorization/create-service/types.ts create mode 100644 hub/server/domains/prototype-authorization/evaluate-access/index.test.ts create mode 100644 hub/server/domains/prototype-authorization/evaluate-access/index.ts create mode 100644 hub/server/domains/prototype-authorization/evaluate-access/types.ts create mode 100644 hub/server/routes/product/index.test.ts create mode 100644 hub/server/routes/product/index.ts diff --git a/hub/docs/README.md b/hub/docs/README.md index 55894251..f08b592c 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. - [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. - [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. +- [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, direct roles, explicit deny, and policy precedence. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index cd180278..ab1e7235 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check and provides GitHub authentication, Hub sessions, and installation membership administration. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, and Hub-native prototype authorization. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page 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 system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. Deployment, background jobs, and prototype APIs have not been built. This app cannot yet 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 system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, and one policy evaluator. Deployment and background jobs have not been built. This app cannot yet find repositories, ingest artifacts, host previews, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication and administration runtime and starts the API at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Product routes create and read workspaces, prototypes, and immutable versions; archive prototypes; explain effective access; and administer direct access rules. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, and prototype authorization runtime and starts the API at a local address. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. diff --git a/hub/docs/database.md b/hub/docs/database.md index f77a896c..f75cece5 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 provider-neutral [identity foundation](identity.md); product models have not been added. +Hub uses PostgreSQL and Prisma. The schema stores the provider-neutral [identity foundation](identity.md) and the [prototype authorization](prototype-authorization.md) product core. [Compose](../.devcontainer/compose.yaml) pins the development database image. [package.json](../package.json) pins the Prisma packages to the same version. Dependency updates must follow the [seven-day dependency policy](../../docs/testing-and-review.md#update-dependencies). @@ -46,6 +46,8 @@ model Folder { `uuid()` generates UUIDs through Prisma. `@updatedAt` updates timestamps on Prisma writes, not through a database trigger. Raw SQL writers must supply UUIDs and maintain `updated_at` themselves. Use timezone-aware timestamps for stored instants. +Workspace and prototype access use separate grant tables so each user has at most one rule per scope. The database requires an allowed rule to have a role and an explicit deny to have no role. Prototype versions have a unique sequence within their owning prototype and use restrictive deletion so history cannot be detached or silently removed. + ## Shared client Import `prisma` from `server/integrations/prisma/index.ts` rather than constructing clients in each caller. The integration uses Prisma's [documented global cache pattern](https://www.prisma.io/docs/orm/v7/more/troubleshooting/nextjs#avoiding-multiple-prisma-client-instances) to reuse one client across development module reloads. Production uses the module instance without setting the global variable. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 08691490..d762df4e 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -25,7 +25,7 @@ Sign-out revokes the current database session. Every restored session requires a - `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. +- `SecurityAudit` stores the event and minimal optional identity and product context. Audit records describe changes but do not grant access. Store operations can share one database transaction and lock a fixed store-state row. Related membership, invitation, session, and audit changes therefore commit or roll back together. Overlapping final-administrator changes and invitation activations observe committed state. @@ -52,6 +52,6 @@ Database credentials use AES-256-GCM with contextual additional authenticated da ## Current scope -This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. It does not add prototype, repository, workspace, or artifact roles; GitHub membership synchronization; artifact ingestion; production OIDC; account linking; SCIM; or prototype APIs. +This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary uses these sessions without treating membership or repository access as product authority. Identity does not add GitHub membership synchronization, artifact ingestion, production OIDC, account linking, or SCIM. Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index 0408e3c2..48909fe9 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -9,7 +9,7 @@ Each Hub user has at most one installation membership. | State | Result | | --- | --- | | Active member | May create and restore a Hub session. Product access remains a separate future decision. | -| Active administrator | Has member access and may use installation administration after fresh identity verification. | +| Active administrator | Has member access and may use installation administration after fresh identity verification. Existing product access remains separate. | | Suspended | Cannot create or use a Hub session. An administrator may restore the membership. | | Removed | Cannot create or use a Hub session. The retained record preserves audit linkage, and return requires a new invitation. | @@ -57,4 +57,4 @@ Membership operations write a structured security record with a bounded action, ## Non-goals -This boundary does not define prototype, repository, workspace, or artifact roles. It does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. +This boundary does not grant prototype, repository, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines Hub-managed product access. Membership does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md new file mode 100644 index 00000000..17e97e36 --- /dev/null +++ b/hub/docs/prototype-authorization.md @@ -0,0 +1,71 @@ +# Prototype authorization + +Hub owns prototype access after authentication and installation admission. A user does not need repository access to use a Hub-managed prototype, and GitHub repository permissions do not grant Hub access. + +## Product records + +A workspace owns prototypes. A prototype owns an ordered history of immutable versions. + +- An active installation administrator may create a workspace. Creation also writes an explicit workspace `administrator` grant for that user in the same transaction. +- A workspace `editor` or `administrator` may create a prototype. +- A prototype `editor` or `administrator` may create its next version. +- A version cannot move to another prototype or be changed after creation. +- Archiving a prototype keeps its versions readable but prevents new versions. + +Artifact payloads are not part of this layer. A version identifies immutable prototype history without storing a build, manifest, or upload. + +## Roles + +| Role | Product authority | +| --- | --- | +| `viewer` | Read the authorized workspace, prototype, and versions. | +| `commenter` | Includes viewer authority and reserves the role for future review comments. | +| `editor` | Includes commenter authority, creates prototypes within an authorized workspace, creates versions, and archives prototypes. | +| `administrator` | Includes editor authority and changes direct access rules. | + +Installation membership and installation administrator status do not grant access to existing workspaces or prototypes. The workspace-creation rule is the only installation-administrator product rule in this layer. After creation, the user's explicit workspace grant supplies authority. + +## Direct access and precedence + +Hub stores one direct workspace rule and one direct prototype rule per user and scope. A rule is either an explicit deny or an allowed role. Removing a rule restores evaluation from the remaining sources. + +The evaluator applies this order: + +1. An explicit Hub deny at either the workspace or prototype scope denies access. +2. An explicit prototype role applies, including a lower role that intentionally restricts a stronger workspace role. +3. An explicit workspace role applies. +4. A future optional synchronized baseline may apply. +5. Otherwise Hub denies access. + +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. + +Workspace administrators cannot remove or replace the final direct workspace administrator. A prototype access change must also leave at least one effective prototype administrator. A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. + +## API and request boundaries + +The product API uses: + +- `GET` and `POST /api/workspaces` +- `GET /api/workspaces/:workspaceId` +- `PUT` and `DELETE /api/workspaces/:workspaceId/access/:userId` +- `GET` and `POST /api/workspaces/:workspaceId/prototypes` +- `GET /api/prototypes/:prototypeId` +- `POST /api/prototypes/:prototypeId/archive` +- `GET /api/prototypes/:prototypeId/access/explanation` +- `PUT` and `DELETE /api/prototypes/:prototypeId/access/:userId` +- `GET` and `POST /api/prototypes/:prototypeId/versions` +- `GET /api/prototypes/:prototypeId/versions/:versionId` + +List routes use an opaque UUID cursor and a limit from 1 through 100. Invalid pagination returns an explicit error. State-changing requests require the configured origin and matching CSRF cookie and header. Product responses are private and non-cacheable, request payloads are omitted from logs, and mutation routes use a bounded rate limit. + +Every product route restores the current session and active installation membership before evaluating product access. Workspace creation and direct access changes also require identity verification from the last five minutes. Mutations repeat the membership check inside their database transaction so a suspended or removed actor cannot complete a stale request. + +## Audits + +Workspace creation, prototype creation and archival, version creation, role grants, explicit denies, and rule removal write structured security audits in the same transaction as the product change. A failed audit rolls back the product change. + +Records identify the actor, session, authentication-provider revision, affected product records, target user when applicable, outcome, and a bounded reason. They do not include cookies, provider tokens, host secrets, request bodies, prototype artifacts, or mutable identity labels. + +## Deferred policy sources + +GitHub repository synchronization, webhooks, reconciliation, and repository-to-role mapping are not implemented. They may later produce an optional baseline role without changing Hub's direct-rule precedence. Source-provider mutations, public links, artifact ingestion, promotion, retention, and rollback remain separate product boundaries. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index a3887a60..cde07ea6 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -55,6 +55,8 @@ Start the PostgreSQL service as described in [Development](development.md) and s 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. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, overlapping grant changes, audit rollback, route enforcement, bounded pagination, version ownership, archival, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. + 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. The database setup follows this lifecycle: diff --git a/hub/schema/access-effect.enum.prisma b/hub/schema/access-effect.enum.prisma new file mode 100644 index 00000000..255e5a9e --- /dev/null +++ b/hub/schema/access-effect.enum.prisma @@ -0,0 +1,4 @@ +enum AccessEffect { + allow + deny +} diff --git a/hub/schema/migrations/20261027220000_prototype_authorization/migration.sql b/hub/schema/migrations/20261027220000_prototype_authorization/migration.sql new file mode 100644 index 00000000..c8321554 --- /dev/null +++ b/hub/schema/migrations/20261027220000_prototype_authorization/migration.sql @@ -0,0 +1,109 @@ +-- CreateEnum +CREATE TYPE "PrototypeRole" AS ENUM ('viewer', 'commenter', 'editor', 'administrator'); + +-- CreateEnum +CREATE TYPE "AccessEffect" AS ENUM ('allow', 'deny'); + +-- CreateTable +CREATE TABLE "workspaces" ( + "id" UUID NOT NULL, + "created_by_user_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "prototypes" ( + "id" UUID NOT NULL, + "workspace_id" UUID NOT NULL, + "created_by_user_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "archived_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "prototypes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "prototype_versions" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "created_by_user_id" UUID NOT NULL, + "sequence" INTEGER NOT NULL, + "label" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "prototype_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_access_grants" ( + "id" UUID NOT NULL, + "workspace_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "effect" "AccessEffect" NOT NULL, + "role" "PrototypeRole", + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_access_grants_pkey" PRIMARY KEY ("id"), + CONSTRAINT "workspace_access_grants_effect_role" CHECK ( + ("effect" = 'deny' AND "role" IS NULL) OR + ("effect" = 'allow' AND "role" IS NOT NULL) + ) +); + +-- CreateTable +CREATE TABLE "prototype_access_grants" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "effect" "AccessEffect" NOT NULL, + "role" "PrototypeRole", + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "prototype_access_grants_pkey" PRIMARY KEY ("id"), + CONSTRAINT "prototype_access_grants_effect_role" CHECK ( + ("effect" = 'deny' AND "role" IS NULL) OR + ("effect" = 'allow' AND "role" IS NOT NULL) + ) +); + +-- AlterTable +ALTER TABLE "security_audits" +ADD COLUMN "workspace_id" UUID, +ADD COLUMN "prototype_id" UUID, +ADD COLUMN "prototype_version_id" UUID, +ADD COLUMN "target_user_id" UUID; + +-- CreateIndex +CREATE INDEX "workspaces_created_by_user_id_idx" ON "workspaces"("created_by_user_id"); +CREATE INDEX "prototypes_workspace_id_idx" ON "prototypes"("workspace_id"); +CREATE INDEX "prototypes_created_by_user_id_idx" ON "prototypes"("created_by_user_id"); +CREATE UNIQUE INDEX "prototype_versions_prototype_id_sequence_key" ON "prototype_versions"("prototype_id", "sequence"); +CREATE INDEX "prototype_versions_created_by_user_id_idx" ON "prototype_versions"("created_by_user_id"); +CREATE UNIQUE INDEX "workspace_access_grants_workspace_id_user_id_key" ON "workspace_access_grants"("workspace_id", "user_id"); +CREATE INDEX "workspace_access_grants_user_id_idx" ON "workspace_access_grants"("user_id"); +CREATE UNIQUE INDEX "prototype_access_grants_prototype_id_user_id_key" ON "prototype_access_grants"("prototype_id", "user_id"); +CREATE INDEX "prototype_access_grants_user_id_idx" ON "prototype_access_grants"("user_id"); +CREATE INDEX "security_audits_workspace_id_idx" ON "security_audits"("workspace_id"); +CREATE INDEX "security_audits_prototype_id_idx" ON "security_audits"("prototype_id"); +CREATE INDEX "security_audits_prototype_version_id_idx" ON "security_audits"("prototype_version_id"); +CREATE INDEX "security_audits_target_user_id_idx" ON "security_audits"("target_user_id"); + +-- AddForeignKey +ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_versions" ADD CONSTRAINT "prototype_versions_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_versions" ADD CONSTRAINT "prototype_versions_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "workspace_access_grants" ADD CONSTRAINT "workspace_access_grants_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "workspace_access_grants" ADD CONSTRAINT "workspace_access_grants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_access_grants" ADD CONSTRAINT "prototype_access_grants_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "prototype_access_grants" ADD CONSTRAINT "prototype_access_grants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/hub/schema/prototype-access-grant.prisma b/hub/schema/prototype-access-grant.prisma new file mode 100644 index 00000000..8aecee23 --- /dev/null +++ b/hub/schema/prototype-access-grant.prisma @@ -0,0 +1,22 @@ +model PrototypeAccessGrant { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + // Attribute columns + effect AccessEffect + role PrototypeRole? + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([prototypeId, userId]) + @@index([userId]) + @@map("prototype_access_grants") +} diff --git a/hub/schema/prototype-role.enum.prisma b/hub/schema/prototype-role.enum.prisma new file mode 100644 index 00000000..41aedcd3 --- /dev/null +++ b/hub/schema/prototype-role.enum.prisma @@ -0,0 +1,6 @@ +enum PrototypeRole { + viewer + commenter + editor + administrator +} diff --git a/hub/schema/prototype-version.prisma b/hub/schema/prototype-version.prisma new file mode 100644 index 00000000..8a60193d --- /dev/null +++ b/hub/schema/prototype-version.prisma @@ -0,0 +1,22 @@ +model PrototypeVersion { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + createdByUserId String @map("created_by_user_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + createdBy User @relation("PrototypeVersionCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + + // Attribute columns + sequence Int + label 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([prototypeId, sequence]) + @@index([createdByUserId]) + @@map("prototype_versions") +} diff --git a/hub/schema/prototype.prisma b/hub/schema/prototype.prisma new file mode 100644 index 00000000..fd2bdfd2 --- /dev/null +++ b/hub/schema/prototype.prisma @@ -0,0 +1,24 @@ +model Prototype { + // ID columns + id String @id @default(uuid()) @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid + createdByUserId String @map("created_by_user_id") @db.Uuid + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("PrototypeCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + versions PrototypeVersion[] + grants PrototypeAccessGrant[] + + // Attribute columns + name String + archivedAt DateTime? @map("archived_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([workspaceId]) + @@index([createdByUserId]) + @@map("prototypes") +} diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index e3bcd5ab..312de7fc 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -6,6 +6,10 @@ model SecurityAudit { authenticationProviderRevisionId String? @map("authentication_provider_revision_id") @db.Uuid targetMembershipId String? @map("target_membership_id") @db.Uuid invitationId String? @map("invitation_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + prototypeId String? @map("prototype_id") @db.Uuid + prototypeVersionId String? @map("prototype_version_id") @db.Uuid + targetUserId String? @map("target_user_id") @db.Uuid // Relations targetMembership InstallationMembership? @relation("TargetMembership", fields: [targetMembershipId], references: [id], onDelete: SetNull) @@ -23,5 +27,9 @@ model SecurityAudit { @@index([createdAt]) @@index([targetMembershipId]) @@index([invitationId]) + @@index([workspaceId]) + @@index([prototypeId]) + @@index([prototypeVersionId]) + @@index([targetUserId]) @@map("security_audits") } diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma index 8ff2cd32..3e9abf4f 100644 --- a/hub/schema/user.prisma +++ b/hub/schema/user.prisma @@ -7,6 +7,11 @@ model User { memberships InstallationMembership[] sessions Session[] createdInvitations MembershipInvitation[] @relation("InvitationCreator") + createdWorkspaces Workspace[] @relation("WorkspaceCreator") + createdPrototypes Prototype[] @relation("PrototypeCreator") + createdVersions PrototypeVersion[] @relation("PrototypeVersionCreator") + workspaceGrants WorkspaceAccessGrant[] + prototypeGrants PrototypeAccessGrant[] // Attribute columns displayName String? @map("display_name") diff --git a/hub/schema/workspace-access-grant.prisma b/hub/schema/workspace-access-grant.prisma new file mode 100644 index 00000000..dbeaeb84 --- /dev/null +++ b/hub/schema/workspace-access-grant.prisma @@ -0,0 +1,22 @@ +model WorkspaceAccessGrant { + // ID columns + id String @id @default(uuid()) @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + // Attribute columns + effect AccessEffect + role PrototypeRole? + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([workspaceId, userId]) + @@index([userId]) + @@map("workspace_access_grants") +} diff --git a/hub/schema/workspace.prisma b/hub/schema/workspace.prisma new file mode 100644 index 00000000..f9d87afb --- /dev/null +++ b/hub/schema/workspace.prisma @@ -0,0 +1,20 @@ +model Workspace { + // ID columns + id String @id @default(uuid()) @db.Uuid + createdByUserId String @map("created_by_user_id") @db.Uuid + + // Relations + createdBy User @relation("WorkspaceCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + prototypes Prototype[] + grants WorkspaceAccessGrant[] + + // Attribute columns + name 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([createdByUserId]) + @@map("workspaces") +} diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index a78512b8..dcdb588d 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -17,7 +17,10 @@ export function createApp({ app.disable("x-powered-by") app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) - app.use(["/api/auth", "/api/session", "/api/administration"], omitRequestLogPayload) + app.use( + ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], + omitRequestLogPayload, + ) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index cd6a7d1b..f4d0a627 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -4,6 +4,7 @@ import { createAuthenticationService } from "#server/domains/authentication/crea import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" import { createIdentityStore } from "#server/domains/identity/create-store" +import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" @@ -83,6 +84,7 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv } return { administration: createAdministrationService({ store }), + prototypeAuthorization: createPrototypeAuthorizationService({ database: prisma, store }), service: createAuthenticationService({ store, github: configuration ? createGitHubUserAuthClient(configuration) : null, diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts new file mode 100644 index 00000000..93a4bb8e --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createIdentityStore } from "#server/domains/identity/create-store" +import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { + prisma, + store: createIdentityStore(prisma), + service: createPrototypeAuthorizationService({ + database: prisma, + store: createIdentityStore(prisma), + }), + } +} + +async function createActor( + store: IdentityStore, + { + subject, + token, + role = "member", + }: { + subject: string + token: string + role?: "member" | "administrator" + }, +) { + const authorizedAt = new Date() + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `User ${subject}`, + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `user-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role, + source: "manual", + }) + const session = await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }) + return { membership, session, user } + }) +} + +describe("createPrototypeAuthorizationService() database behavior", () => { + it("separates installation authority from prototype access and explicitly grants a creator", async () => { + const { prisma, service, store } = await database() + const administrator = await createActor(store, { + subject: "administrator", + token: "administrator-token", + role: "administrator", + }) + const member = await createActor(store, { + subject: "member", + token: "member-token", + }) + const existingWorkspace = await prisma.workspace.create({ + data: { + name: "Existing workspace", + createdByUserId: member.user.id, + }, + }) + + await expect( + service.getWorkspace("administrator-token", existingWorkspace.id), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + await prisma.workspaceAccessGrant.create({ + data: { + workspaceId: existingWorkspace.id, + userId: member.user.id, + effect: "allow", + role: "viewer", + }, + }) + await expect(service.getWorkspace("member-token", existingWorkspace.id)).resolves.toMatchObject( + { + id: existingWorkspace.id, + }, + ) + + const created = (await service.createWorkspace("administrator-token", { + name: "Created workspace", + })) as { id: string } + + await expect(service.getWorkspace("administrator-token", created.id)).resolves.toMatchObject({ + id: created.id, + createdByUserId: administrator.user.id, + }) + await expect(service.getWorkspace("member-token", created.id)).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: created.id, + userId: administrator.user.id, + }, + }, + }), + ).toMatchObject({ + effect: "allow", + role: "administrator", + }) + }) + + it("isolates grants and immutable versions by prototype", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { subject: "isolated", token: "isolated-token" }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const [allowedPrototype, otherPrototype] = await Promise.all([ + prisma.prototype.create({ + data: { + name: "Allowed", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }), + prisma.prototype.create({ + data: { + name: "Other", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }), + ]) + const otherVersion = await prisma.prototypeVersion.create({ + data: { + prototypeId: otherPrototype.id, + createdByUserId: actor.user.id, + sequence: 1, + label: "Other version", + }, + }) + + await expect( + service.getPrototype("isolated-token", allowedPrototype.id), + ).resolves.toMatchObject({ + id: allowedPrototype.id, + }) + await expect(service.getPrototype("isolated-token", otherPrototype.id)).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + await expect( + service.getVersion("isolated-token", allowedPrototype.id, otherVersion.id), + ).rejects.toMatchObject({ + status: 404, + code: "version_not_found", + }) + }) + + it("rechecks membership in the mutation transaction instead of trusting a stale session", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "stale", + token: "stale-token", + role: "administrator", + }) + const target = await createActor(store, { subject: "stale-target", token: "target-token" }) + const workspace = (await service.createWorkspace("stale-token", { + name: "Workspace", + })) as { id: string } + + await store.lock((records) => + records.saveMembership({ + userId: actor.user.id, + status: "suspended", + role: "administrator", + source: "manual", + suspendedAt: new Date("2026-10-27T18:01:00.000Z"), + }), + ) + + await expect( + service.setWorkspaceAccess("stale-token", workspace.id, target.user.id, { + effect: "allow", + role: "viewer", + }), + ).rejects.toMatchObject({ + status: 401, + code: "membership_inactive", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: target.user.id, + }, + }, + }), + ).toBeNull() + expect( + await prisma.securityAudit.count({ + where: { targetUserId: target.user.id }, + }), + ).toBe(0) + }) + + it("commits one valid rule and an audit for each successful concurrent access change", async () => { + const { prisma, service, store } = await database() + const administrator = await createActor(store, { + subject: "concurrent-administrator", + token: "concurrent-administrator-token", + role: "administrator", + }) + const target = await createActor(store, { + subject: "concurrent-target", + token: "concurrent-target-token", + }) + const workspace = (await service.createWorkspace("concurrent-administrator-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype( + "concurrent-administrator-token", + workspace.id, + { name: "Prototype" }, + )) as { id: string } + + const results = await Promise.allSettled([ + service.setPrototypeAccess("concurrent-administrator-token", prototype.id, target.user.id, { + effect: "allow", + role: "editor", + }), + service.setPrototypeAccess("concurrent-administrator-token", prototype.id, target.user.id, { + effect: "deny", + }), + ]) + + const fulfilled = results.filter(({ status }) => status === "fulfilled") + const grant = await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: target.user.id, + }, + }, + }) + const audits = await prisma.securityAudit.findMany({ + where: { + prototypeId: prototype.id, + targetUserId: target.user.id, + }, + orderBy: { createdAt: "asc" }, + }) + + expect(fulfilled.length).toBeGreaterThan(0) + expect(grant).toMatchObject( + grant?.effect === "deny" + ? { effect: "deny", role: null } + : { effect: "allow", role: "editor" }, + ) + expect(audits).toHaveLength(fulfilled.length) + expect(audits.map(({ event }) => event).sort()).toEqual( + fulfilled.length === 2 + ? ["prototype.access.deny", "prototype.access.grant"] + : [grant?.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant"], + ) + expect(audits.every(({ actorUserId }) => actorUserId === administrator.user.id)).toBe(true) + }) + + it("rolls back an access change when its audit cannot be saved", async () => { + const { prisma, service, store } = await database() + await createActor(store, { + subject: "rollback-administrator", + token: "rollback-administrator-token", + role: "administrator", + }) + const target = await createActor(store, { + subject: "rollback-target", + token: "rollback-target-token", + }) + const workspace = (await service.createWorkspace("rollback-administrator-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype("rollback-administrator-token", workspace.id, { + name: "Prototype", + })) as { id: string } + + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_prototype_access_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = 'prototype.access.grant' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_prototype_access_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_prototype_access_audit(); + `) + try { + await expect( + service.setPrototypeAccess("rollback-administrator-token", prototype.id, target.user.id, { + effect: "allow", + role: "viewer", + }), + ).rejects.toThrow("audit unavailable") + } finally { + await prisma.$executeRawUnsafe( + "DROP TRIGGER reject_prototype_access_audit ON security_audits", + ) + await prisma.$executeRawUnsafe("DROP FUNCTION reject_prototype_access_audit()") + } + + expect( + await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: target.user.id, + }, + }, + }), + ).toBeNull() + expect( + await prisma.securityAudit.count({ + where: { + event: "prototype.access.grant", + prototypeId: prototype.id, + targetUserId: target.user.id, + }, + }), + ).toBe(0) + }) + + it("bounds pages at 100, follows the cursor, and rejects larger limits", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { subject: "pagination", token: "pagination-token" }) + const workspaces = await Promise.all( + Array.from({ length: 101 }, (_, index) => + prisma.workspace.create({ + data: { + name: `Workspace ${index.toString().padStart(3, "0")}`, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }), + ), + ) + + const first = (await service.listWorkspaces("pagination-token", { + limit: 100, + })) as { items: { id: string }[]; nextCursor: string | null } + const second = (await service.listWorkspaces("pagination-token", { + limit: 100, + cursor: first.nextCursor, + })) as { items: { id: string }[]; nextCursor: string | null } + + expect(first.items).toHaveLength(100) + expect(first.nextCursor).toBe(first.items.at(-1)?.id) + expect(second.items).toHaveLength(1) + expect(second.nextCursor).toBeNull() + expect(new Set([...first.items, ...second.items].map(({ id }) => id))).toEqual( + new Set(workspaces.map(({ id }) => id)), + ) + await expect(service.listWorkspaces("pagination-token", { limit: 101 })).rejects.toMatchObject({ + status: 422, + code: "invalid_pagination", + }) + }) + + it("creates sequential immutable versions and rejects additions after archival", async () => { + const { prisma, service, store } = await database() + await createActor(store, { + subject: "versions", + token: "versions-token", + role: "administrator", + }) + const workspace = (await service.createWorkspace("versions-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype("versions-token", workspace.id, { + name: "Prototype", + })) as { id: string } + + const created = await Promise.all([ + service.createVersion("versions-token", prototype.id, { label: "First request" }), + service.createVersion("versions-token", prototype.id, { label: "Second request" }), + ]) + + expect( + created + .map((version) => (version as { sequence: number }).sequence) + .sort((left, right) => left - right), + ).toEqual([1, 2]) + expect( + await prisma.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { sequence: "asc" }, + select: { sequence: true }, + }), + ).toEqual([{ sequence: 1 }, { sequence: 2 }]) + + await service.archivePrototype("versions-token", prototype.id) + await expect( + service.createVersion("versions-token", prototype.id, { label: "Too late" }), + ).rejects.toMatchObject({ + status: 409, + code: "prototype_archived", + }) + expect(await prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } })).toBe(2) + const audits = await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { createdAt: "asc" }, + select: { + event: true, + context: true, + prototypeVersionId: true, + }, + }) + expect(audits.map(({ event }) => event)).toEqual([ + "prototype.create", + "prototype.version.create", + "prototype.version.create", + "prototype.archive", + ]) + expect(audits.every(({ context }) => JSON.stringify(context) === "{}")).toBe(true) + expect(audits[0]?.prototypeVersionId).toBeNull() + expect(audits[1]?.prototypeVersionId).toEqual(expect.any(String)) + expect(audits[2]?.prototypeVersionId).toEqual(expect.any(String)) + expect(audits[3]?.prototypeVersionId).toBeNull() + }) +}) diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts new file mode 100644 index 00000000..de9ce98f --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -0,0 +1,588 @@ +import { z } from "zod" + +import { createIdentityRecords } from "#server/domains/identity/create-records" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import { + type AccessDecision, + type PrototypeRole, +} from "#server/domains/prototype-authorization/evaluate-access/types" +import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" +import type { + GrantInput, + PrototypeAuthorizationService, +} from "#server/domains/prototype-authorization/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { AuthenticationError } from "#server/utils/authentication-error" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +const nameSchema = z.object({ name: z.string().trim().min(1).max(120) }) +const versionSchema = z.object({ label: z.string().trim().min(1).max(120).nullable().optional() }) +const pageSchema = z.object({ + cursor: z.uuid().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}) +const grantSchema = z.discriminatedUnion("effect", [ + z.object({ effect: z.literal("deny") }), + z.object({ + effect: z.literal("allow"), + role: z.enum(["viewer", "commenter", "editor", "administrator"]), + }), +]) +const roles: PrototypeRole[] = ["viewer", "commenter", "editor", "administrator"] +const identifierSchema = z.uuid() + +function authenticationDependencies(store: IdentityStore) { + return { + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(false) }, + now: () => new Date(), + } +} + +function parsePage(query: unknown) { + const parsed = pageSchema.safeParse(query) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_pagination", "Pagination is invalid.") + } + return parsed.data +} + +function parseName(input: unknown, code: string, message: string) { + const parsed = nameSchema.safeParse(input) + if (!parsed.success) throw new AuthenticationError(422, code, message) + return parsed.data +} + +function parseGrant(input: unknown): GrantInput { + const parsed = grantSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_access_change", "The access change is invalid.") + } + return parsed.data.effect === "deny" + ? { effect: "deny", role: null } + : { effect: "allow", role: parsed.data.role } +} + +function hasRole(actual: PrototypeRole | null, required: PrototypeRole): boolean { + return actual !== null && roles.indexOf(actual) >= roles.indexOf(required) +} + +function requireIdentifier(value: string, code: string, message: string): void { + if (!identifierSchema.safeParse(value).success) { + throw new AuthenticationError(404, code, message) + } +} + +function requireDecision(decision: AccessDecision, required: PrototypeRole): void { + if (!decision.allowed || !hasRole(decision.role, required)) { + throw new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied.") + } +} + +function requireFreshAuthorization(actor: Awaited>): void { + if (!actor.freshAuthorizedAt || Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Verify your identity again.", + ) + } +} + +async function currentSession( + store: IdentityStore, + sessionToken: string, + database?: Prisma.TransactionClient, +) { + return getCurrentSession( + authenticationDependencies(store), + sessionToken, + database ? createIdentityRecords(database) : store.read, + ) +} + +async function requireActiveTarget(database: Prisma.TransactionClient, userId: string) { + if (!identifierSchema.safeParse(userId).success) { + throw new AuthenticationError(422, "target_user_inactive", "The target user is not active.") + } + const membership = await database.installationMembership.findUnique({ where: { userId } }) + if (!membership || membership.status !== "active") { + throw new AuthenticationError(422, "target_user_inactive", "The target user is not active.") + } +} + +async function workspaceDecision( + database: Prisma.TransactionClient, + userId: string, + workspaceId: string, +) { + requireIdentifier(workspaceId, "workspace_not_found", "The workspace was not found.") + const workspace = await database.workspace.findUnique({ + where: { id: workspaceId }, + include: { grants: { where: { userId } } }, + }) + if (!workspace) { + throw new AuthenticationError(404, "workspace_not_found", "The workspace was not found.") + } + return { + workspace, + decision: evaluatePrototypeAccess({ workspaceGrant: workspace.grants[0] ?? null }), + } +} + +async function prototypeDecision( + database: Prisma.TransactionClient, + userId: string, + prototypeId: string, +) { + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + const prototype = await database.prototype.findUnique({ + where: { id: prototypeId }, + include: { + grants: { where: { userId } }, + workspace: { include: { grants: { where: { userId } } } }, + }, + }) + if (!prototype) { + throw new AuthenticationError(404, "prototype_not_found", "The prototype was not found.") + } + return { + prototype, + decision: evaluatePrototypeAccess({ + workspaceGrant: prototype.workspace.grants[0] ?? null, + prototypeGrant: prototype.grants[0] ?? null, + }), + } +} + +async function hasPrototypeAdministratorAfter( + database: Prisma.TransactionClient, + prototypeId: string, + targetUserId: string, + replacement: GrantInput | null, +): Promise { + const prototype = await database.prototype.findUnique({ + where: { id: prototypeId }, + include: { grants: true, workspace: { include: { grants: true } } }, + }) + if (!prototype) return false + const userIds = new Set([ + ...prototype.workspace.grants.map(({ userId }) => userId), + ...prototype.grants.map(({ userId }) => userId), + targetUserId, + ]) + return [...userIds].some((userId) => { + const workspaceGrant = prototype.workspace.grants.find((grant) => grant.userId === userId) + const prototypeGrant = + userId === targetUserId + ? replacement + : prototype.grants.find((grant) => grant.userId === userId) + return evaluatePrototypeAccess({ workspaceGrant, prototypeGrant }).role === "administrator" + }) +} + +function pageResult(items: T[], limit: number) { + const hasMore = items.length > limit + const pageItems = hasMore ? items.slice(0, limit) : items + return { + items: pageItems, + nextCursor: hasMore ? (pageItems.at(-1)?.id ?? null) : null, + } +} + +async function audit( + database: Prisma.TransactionClient, + actor: Awaited>, + input: Omit< + Parameters[1], + "actorUserId" | "sessionId" | "authenticationProviderRevisionId" + >, +) { + await saveSecurityAudit(database, { + ...input, + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + }) +} + +export function createPrototypeAuthorizationService({ + database, + store, +}: { + database: PrismaClient + store: IdentityStore +}): PrototypeAuthorizationService { + return { + async listWorkspaces(sessionToken, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + const items = await database.workspace.findMany({ + where: { + grants: { + some: { userId: actor.userId, effect: "allow" }, + none: { userId: actor.userId, effect: "deny" }, + }, + }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + }) + return pageResult(items, limit) + }, + + async createWorkspace(sessionToken, input) { + const { name } = parseName(input, "invalid_workspace", "The workspace is invalid.") + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + if (actor.membership.role !== "administrator") { + throw new AuthenticationError( + 403, + "installation_administrator_required", + "Installation administrator access is required.", + ) + } + const workspace = await transaction.workspace.create({ + data: { + name, + createdByUserId: actor.userId, + grants: { + create: { + userId: actor.userId, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + await audit(transaction, actor, { + event: "workspace.create", + workspaceId: workspace.id, + context: { source: "installation_administrator" }, + }) + return workspace + }) + }, + + async getWorkspace(sessionToken, workspaceId) { + const actor = await currentSession(store, sessionToken) + const { workspace, decision } = await workspaceDecision(database, actor.userId, workspaceId) + requireDecision(decision, "viewer") + return { + id: workspace.id, + createdByUserId: workspace.createdByUserId, + name: workspace.name, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + } + }, + + async setWorkspaceAccess(sessionToken, workspaceId, targetUserId, input) { + const change = parseGrant(input) + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) + requireDecision(decision, "administrator") + await requireActiveTarget(transaction, targetUserId) + const existing = await transaction.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + }) + const removesAdministrator = + existing?.effect === "allow" && + existing.role === "administrator" && + (change.effect === "deny" || change.role !== "administrator") + if ( + removesAdministrator && + (await transaction.workspaceAccessGrant.count({ + where: { workspaceId, effect: "allow", role: "administrator" }, + })) <= 1 + ) { + throw new AuthenticationError( + 409, + "last_workspace_administrator", + "The final workspace administrator cannot be changed.", + ) + } + const grant = await transaction.workspaceAccessGrant.upsert({ + where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + create: { workspaceId, userId: targetUserId, ...change }, + update: change, + }) + await audit(transaction, actor, { + event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", + workspaceId, + targetUserId, + context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, + }) + return grant + }) + }, + + async removeWorkspaceAccess(sessionToken, workspaceId, targetUserId) { + await database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) + requireDecision(decision, "administrator") + requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") + const existing = await transaction.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + }) + if (!existing) { + throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") + } + if ( + existing.effect === "allow" && + existing.role === "administrator" && + (await transaction.workspaceAccessGrant.count({ + where: { workspaceId, effect: "allow", role: "administrator" }, + })) <= 1 + ) { + throw new AuthenticationError( + 409, + "last_workspace_administrator", + "The final workspace administrator cannot be removed.", + ) + } + await transaction.workspaceAccessGrant.delete({ + where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + }) + await audit(transaction, actor, { + event: "workspace.access.remove", + workspaceId, + targetUserId, + }) + }) + }, + + async listPrototypes(sessionToken, workspaceId, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + const { decision } = await workspaceDecision(database, actor.userId, workspaceId) + requireDecision(decision, "viewer") + const items = await database.prototype.findMany({ + where: { workspaceId }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + }) + return pageResult(items, limit) + }, + + async createPrototype(sessionToken, workspaceId, input) { + const { name } = parseName(input, "invalid_prototype", "The prototype is invalid.") + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) + requireDecision(decision, "editor") + const prototype = await transaction.prototype.create({ + data: { workspaceId, name, createdByUserId: actor.userId }, + }) + await audit(transaction, actor, { + event: "prototype.create", + workspaceId, + prototypeId: prototype.id, + }) + return prototype + }) + }, + + async getPrototype(sessionToken, prototypeId) { + const actor = await currentSession(store, sessionToken) + const { prototype, decision } = await prototypeDecision(database, actor.userId, prototypeId) + requireDecision(decision, "viewer") + return { + id: prototype.id, + workspaceId: prototype.workspaceId, + createdByUserId: prototype.createdByUserId, + name: prototype.name, + archivedAt: prototype.archivedAt, + createdAt: prototype.createdAt, + updatedAt: prototype.updatedAt, + } + }, + + async archivePrototype(sessionToken, prototypeId) { + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "editor") + if (prototype.archivedAt) { + throw new AuthenticationError( + 409, + "prototype_archived", + "The prototype is already archived.", + ) + } + const archived = await transaction.prototype.update({ + where: { id: prototypeId }, + data: { archivedAt: new Date() }, + }) + await audit(transaction, actor, { + event: "prototype.archive", + workspaceId: prototype.workspaceId, + prototypeId, + }) + return archived + }) + }, + + async explainPrototypeAccess(sessionToken, prototypeId) { + const actor = await currentSession(store, sessionToken) + const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + return decision + }, + + async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { + const change = parseGrant(input) + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "administrator") + await requireActiveTarget(transaction, targetUserId) + if ( + !(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetUserId, change)) + ) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "The final prototype administrator cannot be changed.", + ) + } + const grant = await transaction.prototypeAccessGrant.upsert({ + where: { prototypeId_userId: { prototypeId, userId: targetUserId } }, + create: { prototypeId, userId: targetUserId, ...change }, + update: change, + }) + await audit(transaction, actor, { + event: change.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant", + workspaceId: prototype.workspaceId, + prototypeId, + targetUserId, + context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, + }) + return grant + }) + }, + + async removePrototypeAccess(sessionToken, prototypeId, targetUserId) { + await database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "administrator") + requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") + if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetUserId, null))) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "The final prototype administrator cannot be removed.", + ) + } + const removed = await transaction.prototypeAccessGrant.deleteMany({ + where: { prototypeId, userId: targetUserId }, + }) + if (removed.count === 0) { + throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") + } + await audit(transaction, actor, { + event: "prototype.access.remove", + workspaceId: prototype.workspaceId, + prototypeId, + targetUserId, + }) + }) + }, + + async listVersions(sessionToken, prototypeId, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + requireDecision(decision, "viewer") + const items = await database.prototypeVersion.findMany({ + where: { prototypeId }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + }) + return pageResult(items, limit) + }, + + async createVersion(sessionToken, prototypeId, input) { + const parsed = versionSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_version", "The version is invalid.") + } + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "editor") + if (prototype.archivedAt) { + throw new AuthenticationError( + 409, + "prototype_archived", + "Archived prototypes cannot receive versions.", + ) + } + await transaction.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` + const latest = await transaction.prototypeVersion.findFirst({ + where: { prototypeId }, + orderBy: { sequence: "desc" }, + }) + const version = await transaction.prototypeVersion.create({ + data: { + prototypeId, + createdByUserId: actor.userId, + sequence: (latest?.sequence ?? 0) + 1, + label: parsed.data.label ?? null, + }, + }) + await audit(transaction, actor, { + event: "prototype.version.create", + workspaceId: prototype.workspaceId, + prototypeId, + prototypeVersionId: version.id, + }) + return version + }) + }, + + async getVersion(sessionToken, prototypeId, versionId) { + const actor = await currentSession(store, sessionToken) + const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + requireDecision(decision, "viewer") + requireIdentifier(versionId, "version_not_found", "The version was not found.") + const version = await database.prototypeVersion.findFirst({ + where: { id: versionId, prototypeId }, + }) + if (!version) { + throw new AuthenticationError(404, "version_not_found", "The version was not found.") + } + return version + }, + } +} diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts new file mode 100644 index 00000000..20f044af --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -0,0 +1,47 @@ +import type { AccessDecision, PrototypeRole } from "../evaluate-access/types.ts" + +export interface Page { + items: T[] + nextCursor: string | null +} + +export interface PrototypeAuthorizationService { + listWorkspaces(sessionToken: string, query: unknown): Promise> + createWorkspace(sessionToken: string, input: unknown): Promise + getWorkspace(sessionToken: string, workspaceId: string): Promise + setWorkspaceAccess( + sessionToken: string, + workspaceId: string, + targetUserId: string, + input: unknown, + ): Promise + removeWorkspaceAccess( + sessionToken: string, + workspaceId: string, + targetUserId: string, + ): Promise + listPrototypes(sessionToken: string, workspaceId: string, query: unknown): Promise> + createPrototype(sessionToken: string, workspaceId: string, input: unknown): Promise + getPrototype(sessionToken: string, prototypeId: string): Promise + archivePrototype(sessionToken: string, prototypeId: string): Promise + explainPrototypeAccess(sessionToken: string, prototypeId: string): Promise + setPrototypeAccess( + sessionToken: string, + prototypeId: string, + targetUserId: string, + input: unknown, + ): Promise + removePrototypeAccess( + sessionToken: string, + prototypeId: string, + targetUserId: string, + ): Promise + listVersions(sessionToken: string, prototypeId: string, query: unknown): Promise> + createVersion(sessionToken: string, prototypeId: string, input: unknown): Promise + getVersion(sessionToken: string, prototypeId: string, versionId: string): Promise +} + +export interface GrantInput { + effect: "allow" | "deny" + role: PrototypeRole | null +} diff --git a/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts new file mode 100644 index 00000000..e3fab4a9 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest" + +import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" + +describe("evaluatePrototypeAccess()", () => { + it("uses a prototype role even when it restricts a stronger workspace role", () => { + expect( + evaluatePrototypeAccess({ + workspaceGrant: { effect: "allow", role: "administrator" }, + prototypeGrant: { effect: "allow", role: "viewer" }, + baselineRole: "editor", + }), + ).toEqual({ + allowed: true, + role: "viewer", + source: "hub-prototype", + reason: "prototype_role", + }) + }) + + it.each([ + { + name: "prototype deny", + workspaceGrant: { effect: "allow", role: "administrator" } as const, + prototypeGrant: { effect: "deny", role: null } as const, + source: "hub-prototype", + }, + { + name: "workspace deny", + workspaceGrant: { effect: "deny", role: null } as const, + prototypeGrant: { effect: "allow", role: "administrator" } as const, + source: "hub-workspace", + }, + ])("lets an explicit $name override every allow source", (input) => { + expect( + evaluatePrototypeAccess({ + workspaceGrant: input.workspaceGrant, + prototypeGrant: input.prototypeGrant, + baselineRole: "administrator", + }), + ).toEqual({ + allowed: false, + role: null, + source: input.source, + reason: "explicit_deny", + }) + }) + + it("orders workspace access before a future baseline", () => { + expect( + evaluatePrototypeAccess({ + workspaceGrant: { effect: "allow", role: "commenter" }, + baselineRole: "administrator", + }), + ).toEqual({ + allowed: true, + role: "commenter", + source: "hub-workspace", + reason: "workspace_role", + }) + + expect(evaluatePrototypeAccess({ baselineRole: "editor" })).toEqual({ + allowed: true, + role: "editor", + source: "baseline", + reason: "baseline_role", + }) + }) + + it("defaults to an explainable denial when no source grants access", () => { + expect(evaluatePrototypeAccess({})).toEqual({ + allowed: false, + role: null, + source: "none", + reason: "no_access", + }) + }) +}) diff --git a/hub/server/domains/prototype-authorization/evaluate-access/index.ts b/hub/server/domains/prototype-authorization/evaluate-access/index.ts new file mode 100644 index 00000000..2d258e26 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.ts @@ -0,0 +1,49 @@ +import type { + AccessDecision, + AccessInput, +} from "#server/domains/prototype-authorization/evaluate-access/types" + +export function evaluatePrototypeAccess({ + workspaceGrant, + prototypeGrant, + baselineRole = null, +}: AccessInput): AccessDecision { + if (prototypeGrant?.effect === "deny" || workspaceGrant?.effect === "deny") { + return { + allowed: false, + role: null, + source: prototypeGrant?.effect === "deny" ? "hub-prototype" : "hub-workspace", + reason: "explicit_deny", + } + } + if (prototypeGrant?.effect === "allow" && prototypeGrant.role) { + return { + allowed: true, + role: prototypeGrant.role, + source: "hub-prototype", + reason: "prototype_role", + } + } + if (workspaceGrant?.effect === "allow" && workspaceGrant.role) { + return { + allowed: true, + role: workspaceGrant.role, + source: "hub-workspace", + reason: "workspace_role", + } + } + if (baselineRole) { + return { + allowed: true, + role: baselineRole, + source: "baseline", + reason: "baseline_role", + } + } + return { + allowed: false, + role: null, + source: "none", + reason: "no_access", + } +} diff --git a/hub/server/domains/prototype-authorization/evaluate-access/types.ts b/hub/server/domains/prototype-authorization/evaluate-access/types.ts new file mode 100644 index 00000000..0ce29321 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/types.ts @@ -0,0 +1,19 @@ +export type PrototypeRole = "viewer" | "commenter" | "editor" | "administrator" + +export interface AccessGrant { + effect: "allow" | "deny" + role: PrototypeRole | null +} + +export interface AccessDecision { + allowed: boolean + role: PrototypeRole | null + source: "hub-prototype" | "hub-workspace" | "baseline" | "none" + reason: "explicit_deny" | "prototype_role" | "workspace_role" | "baseline_role" | "no_access" +} + +export interface AccessInput { + workspaceGrant?: AccessGrant | null + prototypeGrant?: AccessGrant | null + baselineRole?: PrototypeRole | null +} diff --git a/hub/server/integrations/express/create-rate-limit/index.ts b/hub/server/integrations/express/create-rate-limit/index.ts index 5170ffd2..18eb7f5a 100644 --- a/hub/server/integrations/express/create-rate-limit/index.ts +++ b/hub/server/integrations/express/create-rate-limit/index.ts @@ -6,10 +6,12 @@ export function createRateLimit({ limit, windowMilliseconds, maxEntries = 10_000, + message = "Too many authentication requests.", }: { limit: number windowMilliseconds: number maxEntries?: number + message?: string }) { const entries = new Map() @@ -29,7 +31,7 @@ export function createRateLimit({ entry.count++ entries.set(key, entry) if (entry.count > limit) { - next(new AuthenticationError(429, "rate_limited", "Too many authentication requests.")) + next(new AuthenticationError(429, "rate_limited", message)) return } next() diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts index b67c6235..6b63ca08 100644 --- a/hub/server/repositories/security-audits/save-audit/index.ts +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -24,6 +24,10 @@ export async function saveSecurityAudit(database: Prisma.TransactionClient, data outcome: data.outcome ?? "succeeded", targetMembershipId: data.targetMembershipId ?? null, invitationId: data.invitationId ?? null, + workspaceId: data.workspaceId ?? null, + prototypeId: data.prototypeId ?? null, + prototypeVersionId: data.prototypeVersionId ?? null, + targetUserId: data.targetUserId ?? null, context, }, }) diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index 7283d69c..fe15ab02 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -6,6 +6,10 @@ export interface AuditInput { authenticationProviderRevisionId: string | null targetMembershipId?: string | null invitationId?: string | null + workspaceId?: string | null + prototypeId?: string | null + prototypeVersionId?: string | null + targetUserId?: string | null context?: Record } @@ -18,6 +22,10 @@ export interface SecurityAuditRecord { authenticationProviderRevisionId: string | null targetMembershipId: string | null invitationId: string | null + workspaceId: string | null + prototypeId: string | null + prototypeVersionId: string | null + targetUserId: string | null context: unknown createdAt: Date } diff --git a/hub/server/routes/administration.test.ts b/hub/server/routes/administration.test.ts index 534d8e09..8b45b386 100644 --- a/hub/server/routes/administration.test.ts +++ b/hub/server/routes/administration.test.ts @@ -6,6 +6,7 @@ import type { Mocked } from "vitest" import type { AdministrationService } from "#server/domains/administration/create-service/types" import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" import type { AuthenticationRoutes } from "#server/routes/types" import { AuthenticationError } from "#server/utils/authentication-error" @@ -42,6 +43,7 @@ function runtime( return { service, administration, + prototypeAuthorization: {} as PrototypeAuthorizationService, settings: { origin: "https://hub.example", secureCookies: true, diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 5c6f3b2b..4de27233 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import type { Mocked } from "vitest" import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" import type { AuthenticationRoutes } from "#server/routes/types" import { createLogger } from "#server/integrations/pino/create-logger" import { AuthenticationError } from "#server/utils/authentication-error" @@ -52,6 +53,7 @@ function authentication( }, service, administration, + prototypeAuthorization: {} as PrototypeAuthorizationService, } } diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index e33dda0c..f9ad0ddd 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -16,6 +16,23 @@ import { delete as handleApiSessionDelete } from "#server/routes/api/session/del import { get as handleApiSessionGet } from "#server/routes/api/session/get" import { post as handleApiSessionRevokeAllPost } from "#server/routes/api/session/revoke-all/post" import { post as handleApiSessionVerifyPost } from "#server/routes/api/session/verify/post" +import { + archivePrototype, + createPrototype, + createVersion, + createWorkspace, + explainPrototypeAccess, + getPrototype, + getVersion, + getWorkspace, + listPrototypes, + listVersions, + listWorkspaces, + removePrototypeAccess, + removeWorkspaceAccess, + setPrototypeAccess, + setWorkspaceAccess, +} from "#server/routes/product" import type { AuthenticationRoutes } from "#server/routes/types" /** Keep endpoint registration separate from the app's shared middleware. */ @@ -24,7 +41,7 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi if (!authentication) return app.use( - ["/api/auth", "/api/session", "/api/administration"], + ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], prepareAuthenticationRequest.bind(null, authentication.settings.origin), ) const initiationLimit = createRateLimit({ @@ -73,5 +90,57 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi handleApiAdministrationMembershipsPatch.bind(null, authentication), ) app.use("/api/administration", respondAdministrationError) + const productMutationLimit = createRateLimit({ + limit: 120, + windowMilliseconds: 5 * 60_000, + message: "Too many product requests.", + }) + app.get("/api/workspaces", listWorkspaces.bind(null, authentication)) + app.post("/api/workspaces", productMutationLimit, createWorkspace.bind(null, authentication)) + app.get("/api/workspaces/:workspaceId", getWorkspace.bind(null, authentication)) + app.put( + "/api/workspaces/:workspaceId/access/:userId", + productMutationLimit, + setWorkspaceAccess.bind(null, authentication), + ) + app.delete( + "/api/workspaces/:workspaceId/access/:userId", + productMutationLimit, + removeWorkspaceAccess.bind(null, authentication), + ) + app.get("/api/workspaces/:workspaceId/prototypes", listPrototypes.bind(null, authentication)) + app.post( + "/api/workspaces/:workspaceId/prototypes", + productMutationLimit, + createPrototype.bind(null, authentication), + ) + app.get("/api/prototypes/:prototypeId", getPrototype.bind(null, authentication)) + app.post( + "/api/prototypes/:prototypeId/archive", + productMutationLimit, + archivePrototype.bind(null, authentication), + ) + app.get( + "/api/prototypes/:prototypeId/access/explanation", + explainPrototypeAccess.bind(null, authentication), + ) + app.put( + "/api/prototypes/:prototypeId/access/:userId", + productMutationLimit, + setPrototypeAccess.bind(null, authentication), + ) + app.delete( + "/api/prototypes/:prototypeId/access/:userId", + productMutationLimit, + removePrototypeAccess.bind(null, authentication), + ) + app.get("/api/prototypes/:prototypeId/versions", listVersions.bind(null, authentication)) + app.post( + "/api/prototypes/:prototypeId/versions", + productMutationLimit, + createVersion.bind(null, authentication), + ) + app.get("/api/prototypes/:prototypeId/versions/:versionId", getVersion.bind(null, authentication)) + app.use(["/api/workspaces", "/api/prototypes"], respondAdministrationError) app.use(respondAuthenticationError) } diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts new file mode 100644 index 00000000..a8e195d0 --- /dev/null +++ b/hub/server/routes/product/index.test.ts @@ -0,0 +1,252 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" +import { createLogger } from "#server/integrations/pino/create-logger" +import type { AuthenticationRoutes } from "#server/routes/types" +import { AuthenticationError } from "#server/utils/authentication-error" + +import { createApp } from "../../create-app/index.ts" + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const protectedHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "content-type": "application/json", + "x-csrf-token": csrfToken, +} + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { + prototypeAuthorization: Mocked +} { + const prototypeAuthorization = Object.assign( + { + listWorkspaces: vi.fn(), + createWorkspace: vi.fn(), + getWorkspace: vi.fn(), + setWorkspaceAccess: vi.fn(), + removeWorkspaceAccess: vi.fn(), + listPrototypes: vi.fn(), + createPrototype: vi.fn(), + getPrototype: vi.fn(), + archivePrototype: vi.fn(), + explainPrototypeAccess: vi.fn(), + setPrototypeAccess: vi.fn(), + removePrototypeAccess: vi.fn(), + listVersions: vi.fn(), + createVersion: vi.fn(), + getVersion: vi.fn(), + }, + overrides, + ) as Mocked + return { + service: {} as Mocked, + administration: {} as Mocked, + prototypeAuthorization, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes, logger = createLogger()) { + const server = createServer(createApp({ authentication, logger })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe("product routes", () => { + it.each([ + { method: "GET", path: "/api/workspaces", call: "listWorkspaces" }, + { method: "POST", path: "/api/workspaces", call: "createWorkspace", body: { name: "W" } }, + { method: "GET", path: "/api/workspaces/workspace", call: "getWorkspace" }, + { + method: "PUT", + path: "/api/workspaces/workspace/access/user", + call: "setWorkspaceAccess", + body: { effect: "allow", role: "viewer" }, + }, + { + method: "DELETE", + path: "/api/workspaces/workspace/access/user", + call: "removeWorkspaceAccess", + }, + { + method: "GET", + path: "/api/workspaces/workspace/prototypes", + call: "listPrototypes", + }, + { + method: "POST", + path: "/api/workspaces/workspace/prototypes", + call: "createPrototype", + body: { name: "P" }, + }, + { method: "GET", path: "/api/prototypes/prototype", call: "getPrototype" }, + { + method: "POST", + path: "/api/prototypes/prototype/archive", + call: "archivePrototype", + }, + { + method: "GET", + path: "/api/prototypes/prototype/access/explanation", + call: "explainPrototypeAccess", + }, + { + method: "PUT", + path: "/api/prototypes/prototype/access/user", + call: "setPrototypeAccess", + body: { effect: "deny" }, + }, + { + method: "DELETE", + path: "/api/prototypes/prototype/access/user", + call: "removePrototypeAccess", + }, + { + method: "GET", + path: "/api/prototypes/prototype/versions", + call: "listVersions", + }, + { + method: "POST", + path: "/api/prototypes/prototype/versions", + call: "createVersion", + body: { label: "Version" }, + }, + { + method: "GET", + path: "/api/prototypes/prototype/versions/version", + call: "getVersion", + }, + ] as const)( + "preserves authorization failures for $method $path", + async ({ method, path, call, body }) => { + const authentication = runtime({ + [call]: vi + .fn() + .mockRejectedValue( + new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied."), + ), + }) + const baseUrl = await serve(authentication) + const mutation = method !== "GET" + + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: mutation ? protectedHeaders : { cookie: `__Host-replica-session=${sessionToken}` }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "prototype_access_denied", + message: "Prototype access is denied.", + }) + expect(authentication.prototypeAuthorization[call].mock.calls).toHaveLength(1) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }, + ) + + it.each([ + { + name: "missing origin", + headers: { ...protectedHeaders, origin: undefined }, + }, + { + name: "wrong origin", + headers: { ...protectedHeaders, origin: "https://attacker.example" }, + }, + { + name: "wrong CSRF token", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + }, + ])("rejects a mutation with $name before the product service runs", async ({ headers }) => { + const authentication = runtime() + const baseUrl = await serve(authentication) + const requestHeaders = Object.fromEntries( + Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + + const response = await fetch(`${baseUrl}/api/workspaces`, { + method: "POST", + headers: requestHeaders, + body: JSON.stringify({ name: "Private workspace name" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.prototypeAuthorization.createWorkspace.mock.calls).toHaveLength(0) + }) + + it("omits rejected product query and body values from request logs", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const authentication = runtime() + const baseUrl = await serve(authentication, logger) + + const response = await fetch(`${baseUrl}/api/workspaces?note=private-query-value`, { + method: "POST", + headers: { ...protectedHeaders, origin: "https://attacker.example" }, + body: JSON.stringify({ note: "private-body-value" }), + }) + + expect(response.status).toBe(403) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("query") + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toMatch(/private-query-value|private-body-value/) + }) + + it("allows 120 product mutations per boundary and rejects the next one", async () => { + const authentication = runtime({ + createWorkspace: vi.fn().mockResolvedValue({ id: "workspace" }), + }) + const baseUrl = await serve( + authentication, + createLogger({ destination: { write: () => undefined } }), + ) + const responses = [] + + for (let request = 0; request < 121; request++) { + responses.push( + await fetch(`${baseUrl}/api/workspaces`, { + method: "POST", + headers: protectedHeaders, + body: JSON.stringify({ name: `Workspace ${request}` }), + }), + ) + } + + expect(responses.slice(0, 120).every(({ status }) => status === 201)).toBe(true) + expect(responses[0]?.headers.get("cache-control")).toBe("private, no-store") + expect(responses[120]?.status).toBe(429) + expect(authentication.prototypeAuthorization.createWorkspace.mock.calls).toHaveLength(120) + }) +}) diff --git a/hub/server/routes/product/index.ts b/hub/server/routes/product/index.ts new file mode 100644 index 00000000..d3cac7a5 --- /dev/null +++ b/hub/server/routes/product/index.ts @@ -0,0 +1,219 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +function sessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + return readCookie(request.headers.cookie, cookies.session) +} + +function mutationSessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + return readCookie(request.headers.cookie, cookies.session) +} + +function parameter(request: Request, name: string): string { + const value = request.params[name] + return typeof value === "string" ? value : "" +} + +export async function listWorkspaces( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + response.json( + await authentication.prototypeAuthorization.listWorkspaces( + sessionToken(authentication, request), + request.query, + ), + ) +} + +export async function createWorkspace( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const workspace = await authentication.prototypeAuthorization.createWorkspace( + mutationSessionToken(authentication, request), + request.body, + ) + response.status(201).json({ workspace }) +} + +export async function getWorkspace( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const workspace = await authentication.prototypeAuthorization.getWorkspace( + sessionToken(authentication, request), + parameter(request, "workspaceId"), + ) + response.json({ workspace }) +} + +export async function setWorkspaceAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const access = await authentication.prototypeAuthorization.setWorkspaceAccess( + mutationSessionToken(authentication, request), + parameter(request, "workspaceId"), + parameter(request, "userId"), + request.body, + ) + response.json({ access }) +} + +export async function removeWorkspaceAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await authentication.prototypeAuthorization.removeWorkspaceAccess( + mutationSessionToken(authentication, request), + parameter(request, "workspaceId"), + parameter(request, "userId"), + ) + response.status(204).end() +} + +export async function listPrototypes( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + response.json( + await authentication.prototypeAuthorization.listPrototypes( + sessionToken(authentication, request), + parameter(request, "workspaceId"), + request.query, + ), + ) +} + +export async function createPrototype( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.createPrototype( + mutationSessionToken(authentication, request), + parameter(request, "workspaceId"), + request.body, + ) + response.status(201).json({ prototype }) +} + +export async function getPrototype( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.getPrototype( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + ) + response.json({ prototype }) +} + +export async function archivePrototype( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.archivePrototype( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + ) + response.json({ prototype }) +} + +export async function explainPrototypeAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const decision = await authentication.prototypeAuthorization.explainPrototypeAccess( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + ) + response.json({ decision }) +} + +export async function setPrototypeAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const access = await authentication.prototypeAuthorization.setPrototypeAccess( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "userId"), + request.body, + ) + response.json({ access }) +} + +export async function removePrototypeAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await authentication.prototypeAuthorization.removePrototypeAccess( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "userId"), + ) + response.status(204).end() +} + +export async function listVersions( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + response.json( + await authentication.prototypeAuthorization.listVersions( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + request.query, + ), + ) +} + +export async function createVersion( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const version = await authentication.prototypeAuthorization.createVersion( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + request.body, + ) + response.status(201).json({ version }) +} + +export async function getVersion( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const version = await authentication.prototypeAuthorization.getVersion( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "versionId"), + ) + response.json({ version }) +} diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index 8a775590..b64ee8d9 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -3,9 +3,11 @@ import type { AuthenticationSettings, } from "#server/domains/authentication/create-service/types" import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" export interface AuthenticationRoutes { service: AuthenticationService administration: AdministrationService + prototypeAuthorization: PrototypeAuthorizationService settings: AuthenticationSettings } From 7d2d642e96d21966640d539512665025f03e7ca9 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 14:32:22 -0700 Subject: [PATCH 12/56] fix(hub): serialize prototype administration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/membership-and-administration.md | 2 + hub/docs/prototype-authorization.md | 8 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 170 +++++++ .../administration/create-service/index.ts | 6 +- .../create-service/index.test.ts | 2 +- .../create-service/membership.test.ts | 2 +- .../domains/identity/create-store/types.ts | 5 +- .../domains/identity/with-store-lock/index.ts | 6 +- .../create-service/index.db.test.ts | 455 ++++++++++++++++++ .../create-service/index.ts | 162 +++++-- .../ensure-administration-continuity/index.ts | 112 +++++ 12 files changed, 892 insertions(+), 40 deletions(-) create mode 100644 hub/server/domains/prototype-authorization/ensure-administration-continuity/index.ts diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index 48909fe9..60b9e9b9 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -15,6 +15,8 @@ Each Hub user has at most one installation membership. Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. +Suspension and removal also check Hub-managed product administration in the same transaction. The change fails with `product_administration_orphaned` until another active user can administer every affected workspace and prototype. A role change from installation administrator to member does not remove explicit workspace or prototype roles. + ## Invitations An invitation targets an external identity assignment: diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 17e97e36..c96f6a09 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -10,7 +10,7 @@ A workspace owns prototypes. A prototype owns an ordered history of immutable ve - A workspace `editor` or `administrator` may create a prototype. - A prototype `editor` or `administrator` may create its next version. - A version cannot move to another prototype or be changed after creation. -- Archiving a prototype keeps its versions readable but prevents new versions. +- Archiving a prototype keeps its versions readable but prevents new versions. Archive and version creation lock and re-read the prototype so overlapping requests cannot add a version after archival. Artifact payloads are not part of this layer. A version identifies immutable prototype history without storing a build, manifest, or upload. @@ -39,7 +39,11 @@ The evaluator applies this order: Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. -Workspace administrators cannot remove or replace the final direct workspace administrator. A prototype access change must also leave at least one effective prototype administrator. A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. +Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. + +Suspending or removing a membership fails with `product_administration_orphaned` when that user is the final active administrator for a workspace or prototype. An administrator must transfer product administration first. Changing an active installation administrator to a member does not remove an explicit product role, because installation and product authority remain separate. + +A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. ## API and request boundaries diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index cde07ea6..17f5e176 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -55,7 +55,7 @@ Start the PostgreSQL service as described in [Development](development.md) and s 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. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. -Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, overlapping grant changes, audit rollback, route enforcement, bounded pagination, version ownership, archival, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, deny-aware pagination, version ownership, archive/version races, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. 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/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index 2a8e3133..8027feb6 100644 --- a/hub/server/domains/administration/create-service/index.db.test.ts +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -100,4 +100,174 @@ describe("createAdministrationService() database behavior", () => { }), ]) }) + + it("allows installation role demotion but prevents workspace orphaning on suspension", async () => { + const { prisma, store } = await database() + const actor = await createAdministrator(store, "workspace-actor", "workspace-actor-token") + const target = await createAdministrator(store, "workspace-target", "workspace-target-token") + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: target.user.id, + grants: { + create: { + userId: target.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("workspace-actor-token", target.membership.id, { + role: "member", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + role: "member", + status: "active", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ effect: "allow", role: "administrator" }) + + await expect( + service.changeMembership("workspace-actor-token", target.membership.id, { + status: "suspended", + }), + ).rejects.toMatchObject({ + status: 409, + code: "product_administration_orphaned", + message: "Transfer workspace administration before changing this membership.", + }) + + await prisma.workspaceAccessGrant.create({ + data: { + workspaceId: workspace.id, + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }) + await expect( + service.changeMembership("workspace-actor-token", target.membership.id, { + status: "suspended", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + role: "member", + status: "suspended", + }) + expect(await store.read.session(digestSecret("workspace-target-token"))).toBeNull() + const targetAudits = (await store.read.audits()).filter(({ targetMembershipId }) => { + return targetMembershipId === target.membership.id + }) + expect(targetAudits.every(({ event }) => event === "membership.change")).toBe(true) + expect( + targetAudits.map(({ context }) => (context as { reason: string }).reason).sort(), + ).toEqual(["role_member", "status_suspended"]) + }) + + it("prevents prototype orphaning on removal until an active replacement is assigned", async () => { + const { prisma, store } = await database() + const actor = await createAdministrator(store, "prototype-actor", "prototype-actor-token") + const target = await createAdministrator(store, "prototype-target", "prototype-target-token") + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + { + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + { + userId: target.user.id, + effect: "allow", + role: "administrator", + }, + ], + }, + }, + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("prototype-actor-token", target.membership.id, { + status: "removed", + }), + ).rejects.toMatchObject({ + status: 409, + code: "product_administration_orphaned", + message: "Transfer prototype administration before changing this membership.", + }) + + await prisma.prototypeAccessGrant.update({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: actor.user.id, + }, + }, + data: { role: "administrator" }, + }) + await expect( + service.changeMembership("prototype-actor-token", target.membership.id, { + status: "removed", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + status: "removed", + }) + expect(await store.read.session(digestSecret("prototype-target-token"))).toBeNull() + expect( + await prisma.securityAudit.findMany({ + where: { targetMembershipId: target.membership.id }, + select: { event: true, context: true }, + }), + ).toEqual([ + { + event: "membership.change", + context: { reason: "status_removed" }, + }, + ]) + }) }) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index 4a96589a..fdd8db24 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -5,6 +5,7 @@ import type { AdministrationService, } from "#server/domains/administration/create-service/types" import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { ensureProductAdministrationContinuity } from "#server/domains/prototype-authorization/ensure-administration-continuity" import { AuthenticationError } from "#server/utils/authentication-error" const invitationSchema = z.object({ @@ -183,7 +184,7 @@ export function createAdministrationService( ) } const current = await requireFreshAdministrator(dependencies, sessionToken) - return dependencies.store.lock(async (records) => { + return dependencies.store.lock(async (records, database) => { const actor = await records.sessionById(current.id) const actorMembership = actor ? await records.membership(actor.userId) : null if ( @@ -232,6 +233,9 @@ export function createAdministrationService( "The final active administrator cannot be changed.", ) } + if (target.status === "active" && nextStatus !== "active") { + await ensureProductAdministrationContinuity(database, target.userId) + } const changed = await records.saveMembership({ userId: target.userId, role: nextRole, diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index c29b00f6..b3f025c9 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -73,7 +73,7 @@ function store(identityRecords: TestIdentityRecords): IdentityStore { const typedRecords = identityRecords as unknown as IdentityRecords return { read: typedRecords, - lock: (operation) => operation(typedRecords), + lock: (operation) => operation(typedRecords, {} as never), } } diff --git a/hub/server/domains/authentication/create-service/membership.test.ts b/hub/server/domains/authentication/create-service/membership.test.ts index 7f0f942e..720d7e4d 100644 --- a/hub/server/domains/authentication/create-service/membership.test.ts +++ b/hub/server/domains/authentication/create-service/membership.test.ts @@ -56,7 +56,7 @@ describe("createAuthenticationService() membership enforcement", () => { } as unknown as IdentityRecords const store: IdentityStore = { read: records, - lock: (operation) => operation(records), + lock: (operation) => operation(records, {} as never), } const service = createAuthenticationService({ store, diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index d21f7827..316eda4a 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -18,6 +18,7 @@ import type { AuditInput, SecurityAuditRecord } from "#server/repositories/secur import type { HubSession } from "#server/repositories/sessions/types" import type { HubUser, UserInput } from "#server/repositories/users/types" import type { IdentityStoreState } from "#server/repositories/identity-store-state/types" +import type { Prisma } from "../../../generated/prisma/client.ts" export interface AuthenticationProviderRevisionRecord { id: string @@ -107,5 +108,7 @@ export interface IdentityRecords { export interface IdentityStore { read: IdentityRecords - lock(operation: (records: IdentityRecords) => Promise): Promise + lock( + operation: (records: IdentityRecords, database: Prisma.TransactionClient) => 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..1ed20b19 100644 --- a/hub/server/domains/identity/with-store-lock/index.ts +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -3,14 +3,14 @@ import { ensureGitHubIntegrationState } from "#server/repositories/github-integr 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" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" export function withIdentityStoreLock( client: PrismaClient, - operation: (records: IdentityRecords) => Promise, + operation: (records: IdentityRecords, database: Prisma.TransactionClient) => Promise, ): Promise { return withRepositoryLock(client, async (database) => { await ensureGitHubIntegrationState(database) - return operation(createIdentityRecords(database)) + return operation(createIdentityRecords(database), database) }) } diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 93a4bb8e..41095980 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -4,6 +4,8 @@ import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" import { digestSecret } from "#server/utils/digest-secret" +import type { PrismaClient } from "../../../generated/prisma/client.ts" + type IdentityStore = ReturnType async function database() { @@ -75,6 +77,62 @@ async function createActor( }) } +async function waitForBlockedRowLock(database: PrismaClient, table: string) { + const queryPattern = `%${table}%FOR UPDATE%` + await vi.waitFor( + async () => { + const [result] = await database.$queryRaw<{ blocked: boolean }[]>` + SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' + AND query LIKE ${queryPattern} + ) AS blocked + ` + expect(result?.blocked).toBe(true) + }, + { interval: 20, timeout: 5_000 }, + ) +} + +function deferred() { + let resolvePromise!: () => void + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} + +async function holdWorkspaceLock(database: PrismaClient, workspaceId: string) { + const released = deferred() + const locked = deferred() + const holder = database.$transaction(async (transaction) => { + await transaction.$queryRaw` + SELECT "id" FROM "workspaces" WHERE "id" = ${workspaceId}::uuid FOR UPDATE + ` + locked.resolve() + await released.promise + }) + await locked.promise + return { holder, release: released.resolve } +} + +async function holdPrototypeLock(database: PrismaClient, prototypeId: string) { + const released = deferred() + const locked = deferred() + const holder = database.$transaction(async (transaction) => { + await transaction.$queryRaw` + SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE + ` + locked.resolve() + await released.promise + }) + await locked.promise + return { holder, release: released.resolve } +} + describe("createPrototypeAuthorizationService() database behavior", () => { it("separates installation authority from prototype access and explicitly grants a creator", async () => { const { prisma, service, store } = await database() @@ -247,6 +305,243 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ).toBe(0) }) + it("serializes concurrent workspace administrator removal and downgrade", async () => { + const { prisma, service, store } = await database() + const first = await createActor(store, { + subject: "workspace-administrator-one", + token: "workspace-administrator-one-token", + }) + const second = await createActor(store, { + subject: "workspace-administrator-two", + token: "workspace-administrator-two-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: first.user.id, + grants: { + create: [ + { + userId: first.user.id, + effect: "allow", + role: "administrator", + }, + { + userId: second.user.id, + effect: "allow", + role: "administrator", + }, + ], + }, + }, + }) + const lock = await holdWorkspaceLock(prisma, workspace.id) + + const removal = service.removeWorkspaceAccess( + "workspace-administrator-one-token", + workspace.id, + first.user.id, + ) + await waitForBlockedRowLock(prisma, "workspaces") + const downgrade = service.setWorkspaceAccess( + "workspace-administrator-two-token", + workspace.id, + second.user.id, + { effect: "allow", role: "viewer" }, + ) + lock.release() + await lock.holder + const results = await Promise.allSettled([removal, downgrade]) + + expect(results[0]).toMatchObject({ status: "fulfilled" }) + if (results[1]?.status !== "rejected") throw new Error("The downgrade should be rejected.") + expect(results[1].reason).toMatchObject({ + status: 409, + code: "last_workspace_administrator", + }) + expect( + await prisma.workspaceAccessGrant.findMany({ + where: { workspaceId: workspace.id, effect: "allow", role: "administrator" }, + select: { userId: true }, + }), + ).toEqual([{ userId: second.user.id }]) + expect( + await prisma.securityAudit.findMany({ + where: { workspaceId: workspace.id, event: { startsWith: "workspace.access." } }, + select: { event: true, targetUserId: true }, + }), + ).toEqual([{ event: "workspace.access.remove", targetUserId: first.user.id }]) + }) + + it("lets exactly one prototype-direct administrator relinquish authority", async () => { + const { prisma, service, store } = await database() + const first = await createActor(store, { + subject: "prototype-administrator-one", + token: "prototype-administrator-one-token", + }) + const second = await createActor(store, { + subject: "prototype-administrator-two", + token: "prototype-administrator-two-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: first.user.id, + grants: { + create: [ + { userId: first.user.id, effect: "allow", role: "viewer" }, + { userId: second.user.id, effect: "allow", role: "viewer" }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: first.user.id, + grants: { + create: [ + { userId: first.user.id, effect: "allow", role: "administrator" }, + { userId: second.user.id, effect: "allow", role: "administrator" }, + ], + }, + }, + }) + const lock = await holdPrototypeLock(prisma, prototype.id) + + const removal = service.removePrototypeAccess( + "prototype-administrator-one-token", + prototype.id, + first.user.id, + ) + await waitForBlockedRowLock(prisma, "prototypes") + const downgrade = service.setPrototypeAccess( + "prototype-administrator-two-token", + prototype.id, + second.user.id, + { effect: "allow", role: "viewer" }, + ) + lock.release() + await lock.holder + const results = await Promise.allSettled([removal, downgrade]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + if (results[1]?.status !== "rejected") throw new Error("The downgrade should be rejected.") + expect(results[1].reason).toMatchObject({ + status: 409, + code: "last_prototype_administrator", + }) + expect( + await prisma.prototypeAccessGrant.findMany({ + where: { prototypeId: prototype.id, effect: "allow", role: "administrator" }, + select: { userId: true }, + }), + ).toEqual([{ userId: second.user.id }]) + expect( + await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id, event: { startsWith: "prototype.access." } }, + select: { event: true, targetUserId: true }, + }), + ).toEqual([{ event: "prototype.access.remove", targetUserId: first.user.id }]) + }) + + it("does not count suspended administrators in workspace or prototype continuity", async () => { + const { prisma, service, store } = await database() + const active = await createActor(store, { + subject: "active-product-administrator", + token: "active-product-administrator-token", + }) + const suspended = await createActor(store, { + subject: "suspended-product-administrator", + token: "suspended-product-administrator-token", + }) + const removed = await createActor(store, { + subject: "removed-product-administrator", + token: "removed-product-administrator-token", + }) + await store.lock((records) => + records.saveMembership({ + userId: suspended.user.id, + status: "suspended", + role: "member", + source: "manual", + suspendedAt: new Date(), + }), + ) + await store.lock((records) => + records.saveMembership({ + userId: removed.user.id, + status: "removed", + role: "member", + source: "manual", + removedAt: new Date(), + }), + ) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: active.user.id, + grants: { + create: [ + { userId: active.user.id, effect: "allow", role: "administrator" }, + { userId: suspended.user.id, effect: "allow", role: "administrator" }, + { userId: removed.user.id, effect: "allow", role: "administrator" }, + ], + }, + }, + }) + const prototypeWorkspace = await prisma.workspace.create({ + data: { + name: "Prototype workspace", + createdByUserId: active.user.id, + grants: { + create: [ + { userId: active.user.id, effect: "allow", role: "viewer" }, + { userId: suspended.user.id, effect: "allow", role: "viewer" }, + { userId: removed.user.id, effect: "allow", role: "viewer" }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: prototypeWorkspace.id, + createdByUserId: active.user.id, + grants: { + create: [ + { userId: active.user.id, effect: "allow", role: "administrator" }, + { userId: suspended.user.id, effect: "allow", role: "administrator" }, + { userId: removed.user.id, effect: "allow", role: "administrator" }, + ], + }, + }, + }) + + await expect( + service.removeWorkspaceAccess( + "active-product-administrator-token", + workspace.id, + active.user.id, + ), + ).rejects.toMatchObject({ + status: 409, + code: "last_workspace_administrator", + }) + await expect( + service.removePrototypeAccess( + "active-product-administrator-token", + prototype.id, + active.user.id, + ), + ).rejects.toMatchObject({ + status: 409, + code: "last_prototype_administrator", + }) + }) + it("commits one valid rule and an audit for each successful concurrent access change", async () => { const { prisma, service, store } = await database() const administrator = await createActor(store, { @@ -309,6 +604,108 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(audits.every(({ actorUserId }) => actorUserId === administrator.user.id)).toBe(true) }) + it("commits one concurrent archive and returns one archived conflict", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "concurrent-archive", + token: "concurrent-archive-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "editor", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + const lock = await holdPrototypeLock(prisma, prototype.id) + + const first = service.archivePrototype("concurrent-archive-token", prototype.id) + await waitForBlockedRowLock(prisma, "prototypes") + const second = service.archivePrototype("concurrent-archive-token", prototype.id) + lock.release() + await lock.holder + const results = await Promise.allSettled([first, second]) + + expect(results[0]).toMatchObject({ status: "fulfilled" }) + if (results[1]?.status !== "rejected") throw new Error("The second archive should be rejected.") + expect(results[1].reason).toMatchObject({ + status: 409, + code: "prototype_archived", + }) + expect( + await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id, event: "prototype.archive" }, + select: { event: true }, + }), + ).toEqual([{ event: "prototype.archive" }]) + }) + + it("does not commit a version queued behind archival", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "archive-version-race", + token: "archive-version-race-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "editor", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + const lock = await holdPrototypeLock(prisma, prototype.id) + + const archive = service.archivePrototype("archive-version-race-token", prototype.id) + await waitForBlockedRowLock(prisma, "prototypes") + const version = service.createVersion("archive-version-race-token", prototype.id, { + label: "Must not commit", + }) + lock.release() + await lock.holder + + const archived = (await archive) as { id: string; archivedAt: Date | null } + expect(archived.id).toBe(prototype.id) + expect(archived.archivedAt).toBeInstanceOf(Date) + await expect(version).rejects.toMatchObject({ + status: 409, + code: "prototype_archived", + }) + expect(await prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } })).toBe(0) + expect( + await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { createdAt: "asc" }, + select: { event: true }, + }), + ).toEqual([{ event: "prototype.archive" }]) + }) + it("rolls back an access change when its audit cannot be saved", async () => { const { prisma, service, store } = await database() await createActor(store, { @@ -419,6 +816,64 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "prototype-pagination", + token: "prototype-pagination-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + const prototypeIds = [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000004", + "00000000-0000-4000-8000-000000000005", + "00000000-0000-4000-8000-000000000006", + ] + await prisma.prototype.createMany({ + data: prototypeIds.map((id) => ({ + id, + name: `Prototype ${id.at(-1)}`, + workspaceId: workspace.id, + createdByUserId: actor.user.id, + })), + }) + await prisma.prototypeAccessGrant.createMany({ + data: [prototypeIds[1], prototypeIds[4]].map((prototypeId) => ({ + prototypeId: prototypeId!, + userId: actor.user.id, + effect: "deny", + })), + }) + + const first = (await service.listPrototypes("prototype-pagination-token", workspace.id, { + limit: 2, + })) as { items: { id: string }[]; nextCursor: string | null } + const second = (await service.listPrototypes("prototype-pagination-token", workspace.id, { + limit: 2, + cursor: first.nextCursor, + })) as { items: { id: string }[]; nextCursor: string | null } + + expect(first.items.map(({ id }) => id)).toEqual([prototypeIds[0], prototypeIds[2]]) + expect(first.nextCursor).toBe(prototypeIds[2]) + expect(second.items.map(({ id }) => id)).toEqual([prototypeIds[3], prototypeIds[5]]) + expect(second.nextCursor).toBeNull() + expect(new Set([...first.items, ...second.items].map(({ id }) => id)).size).toBe(4) + }) + it("creates sequential immutable versions and rejects additions after archival", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index de9ce98f..727aebb1 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -116,6 +116,33 @@ async function requireActiveTarget(database: Prisma.TransactionClient, userId: s } } +async function lockWorkspace( + database: Prisma.TransactionClient, + workspaceId: string, +): Promise { + await database.$queryRaw`SELECT "id" FROM "workspaces" WHERE "id" = ${workspaceId}::uuid FOR UPDATE` +} + +async function lockPrototype( + database: Prisma.TransactionClient, + prototypeId: string, +): Promise { + await database.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` +} + +async function lockWorkspacePrototypes( + database: Prisma.TransactionClient, + workspaceId: string, +): Promise { + const prototypes = await database.prototype.findMany({ + where: { workspaceId }, + orderBy: { id: "asc" }, + select: { id: true }, + }) + for (const { id } of prototypes) await lockPrototype(database, id) + return prototypes.map(({ id }) => id) +} + async function workspaceDecision( database: Prisma.TransactionClient, userId: string, @@ -165,6 +192,8 @@ async function hasPrototypeAdministratorAfter( prototypeId: string, targetUserId: string, replacement: GrantInput | null, + scope: "prototype" | "workspace" = "prototype", + excludedUserId?: string, ): Promise { const prototype = await database.prototype.findUnique({ where: { id: prototypeId }, @@ -176,16 +205,55 @@ async function hasPrototypeAdministratorAfter( ...prototype.grants.map(({ userId }) => userId), targetUserId, ]) + const activeMemberships = await database.installationMembership.findMany({ + where: { + userId: { in: [...userIds], ...(excludedUserId ? { not: excludedUserId } : {}) }, + status: "active", + }, + select: { userId: true }, + }) + const activeUserIds = new Set(activeMemberships.map(({ userId }) => userId)) return [...userIds].some((userId) => { - const workspaceGrant = prototype.workspace.grants.find((grant) => grant.userId === userId) + if (!activeUserIds.has(userId)) return false + const workspaceGrant = + scope === "workspace" && userId === targetUserId + ? replacement + : prototype.workspace.grants.find((grant) => grant.userId === userId) const prototypeGrant = - userId === targetUserId + scope === "prototype" && userId === targetUserId ? replacement : prototype.grants.find((grant) => grant.userId === userId) return evaluatePrototypeAccess({ workspaceGrant, prototypeGrant }).role === "administrator" }) } +async function hasWorkspaceAdministratorAfter( + database: Prisma.TransactionClient, + workspaceId: string, + targetUserId: string, + replacement: GrantInput | null, + excludedUserId?: string, +): Promise { + const grants = await database.workspaceAccessGrant.findMany({ where: { workspaceId } }) + const userIds = new Set([...grants.map(({ userId }) => userId), targetUserId]) + const activeMemberships = await database.installationMembership.findMany({ + where: { + userId: { in: [...userIds], ...(excludedUserId ? { not: excludedUserId } : {}) }, + status: "active", + }, + select: { userId: true }, + }) + const activeUserIds = new Set(activeMemberships.map(({ userId }) => userId)) + return [...userIds].some((userId) => { + if (!activeUserIds.has(userId)) return false + const grant = + userId === targetUserId + ? replacement + : grants.find((candidate) => candidate.userId === userId) + return grant?.effect === "allow" && grant.role === "administrator" + }) +} + function pageResult(items: T[], limit: number) { const hasMore = items.length > limit const pageItems = hasMore ? items.slice(0, limit) : items @@ -238,7 +306,7 @@ export function createPrototypeAuthorizationService({ async createWorkspace(sessionToken, input) { const { name } = parseName(input, "invalid_workspace", "The workspace is invalid.") - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) if (actor.membership.role !== "administrator") { @@ -285,24 +353,17 @@ export function createPrototypeAuthorizationService({ async setWorkspaceAccess(sessionToken, workspaceId, targetUserId, input) { const change = parseGrant(input) - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) + requireIdentifier(workspaceId, "workspace_not_found", "The workspace was not found.") + await lockWorkspace(transaction, workspaceId) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "administrator") await requireActiveTarget(transaction, targetUserId) - const existing = await transaction.workspaceAccessGrant.findUnique({ - where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, - }) - const removesAdministrator = - existing?.effect === "allow" && - existing.role === "administrator" && - (change.effect === "deny" || change.role !== "administrator") + const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) if ( - removesAdministrator && - (await transaction.workspaceAccessGrant.count({ - where: { workspaceId, effect: "allow", role: "administrator" }, - })) <= 1 + !(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetUserId, change)) ) { throw new AuthenticationError( 409, @@ -310,6 +371,23 @@ export function createPrototypeAuthorizationService({ "The final workspace administrator cannot be changed.", ) } + for (const prototypeId of prototypeIds) { + if ( + !(await hasPrototypeAdministratorAfter( + transaction, + prototypeId, + targetUserId, + change, + "workspace", + )) + ) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "The access change would leave a prototype without an administrator.", + ) + } + } const grant = await transaction.workspaceAccessGrant.upsert({ where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, create: { workspaceId, userId: targetUserId, ...change }, @@ -326,31 +404,45 @@ export function createPrototypeAuthorizationService({ }, async removeWorkspaceAccess(sessionToken, workspaceId, targetUserId) { - await database.$transaction(async (transaction) => { + await store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) + requireIdentifier(workspaceId, "workspace_not_found", "The workspace was not found.") + await lockWorkspace(transaction, workspaceId) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "administrator") requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") + const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) const existing = await transaction.workspaceAccessGrant.findUnique({ where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, }) if (!existing) { throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") } - if ( - existing.effect === "allow" && - existing.role === "administrator" && - (await transaction.workspaceAccessGrant.count({ - where: { workspaceId, effect: "allow", role: "administrator" }, - })) <= 1 - ) { + if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetUserId, null))) { throw new AuthenticationError( 409, "last_workspace_administrator", "The final workspace administrator cannot be removed.", ) } + for (const prototypeId of prototypeIds) { + if ( + !(await hasPrototypeAdministratorAfter( + transaction, + prototypeId, + targetUserId, + null, + "workspace", + )) + ) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "Removing access would leave a prototype without an administrator.", + ) + } + } await transaction.workspaceAccessGrant.delete({ where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, }) @@ -368,7 +460,10 @@ export function createPrototypeAuthorizationService({ const { decision } = await workspaceDecision(database, actor.userId, workspaceId) requireDecision(decision, "viewer") const items = await database.prototype.findMany({ - where: { workspaceId }, + where: { + workspaceId, + grants: { none: { userId: actor.userId, effect: "deny" } }, + }, orderBy: { id: "asc" }, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, @@ -378,7 +473,7 @@ export function createPrototypeAuthorizationService({ async createPrototype(sessionToken, workspaceId, input) { const { name } = parseName(input, "invalid_prototype", "The prototype is invalid.") - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "editor") @@ -410,8 +505,10 @@ export function createPrototypeAuthorizationService({ }, async archivePrototype(sessionToken, prototypeId) { - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( transaction, actor.userId, @@ -446,9 +543,11 @@ export function createPrototypeAuthorizationService({ async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { const change = parseGrant(input) - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( transaction, actor.userId, @@ -482,9 +581,11 @@ export function createPrototypeAuthorizationService({ }, async removePrototypeAccess(sessionToken, prototypeId, targetUserId) { - await database.$transaction(async (transaction) => { + await store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( transaction, actor.userId, @@ -533,8 +634,10 @@ export function createPrototypeAuthorizationService({ if (!parsed.success) { throw new AuthenticationError(422, "invalid_version", "The version is invalid.") } - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( transaction, actor.userId, @@ -548,7 +651,6 @@ export function createPrototypeAuthorizationService({ "Archived prototypes cannot receive versions.", ) } - await transaction.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` const latest = await transaction.prototypeVersion.findFirst({ where: { prototypeId }, orderBy: { sequence: "desc" }, diff --git a/hub/server/domains/prototype-authorization/ensure-administration-continuity/index.ts b/hub/server/domains/prototype-authorization/ensure-administration-continuity/index.ts new file mode 100644 index 00000000..bf738d7d --- /dev/null +++ b/hub/server/domains/prototype-authorization/ensure-administration-continuity/index.ts @@ -0,0 +1,112 @@ +import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" +import { AuthenticationError } from "#server/utils/authentication-error" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +async function activeUserIds(database: Prisma.TransactionClient, userIds: string[]) { + const memberships = await database.installationMembership.findMany({ + where: { userId: { in: userIds }, status: "active" }, + select: { userId: true }, + }) + return new Set(memberships.map(({ userId }) => userId)) +} + +export async function ensureProductAdministrationContinuity( + database: Prisma.TransactionClient, + userId: string, +): Promise { + const [workspaceGrants, prototypeGrants] = await Promise.all([ + database.workspaceAccessGrant.findMany({ + where: { userId, effect: "allow", role: "administrator" }, + select: { workspaceId: true }, + }), + database.prototypeAccessGrant.findMany({ + where: { userId, effect: "allow", role: "administrator" }, + select: { prototypeId: true, prototype: { select: { workspaceId: true } } }, + }), + ]) + const workspaceIds = [ + ...new Set([ + ...workspaceGrants.map(({ workspaceId }) => workspaceId), + ...prototypeGrants.map(({ prototype }) => prototype.workspaceId), + ]), + ].sort() + for (const workspaceId of workspaceIds) { + await database.$queryRaw`SELECT "id" FROM "workspaces" WHERE "id" = ${workspaceId}::uuid FOR UPDATE` + } + const prototypes = await database.prototype.findMany({ + where: { + OR: [ + { workspaceId: { in: workspaceIds } }, + { id: { in: prototypeGrants.map(({ prototypeId }) => prototypeId) } }, + ], + }, + orderBy: { id: "asc" }, + include: { grants: true, workspace: { include: { grants: true } } }, + }) + for (const { id } of prototypes) { + await database.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${id}::uuid FOR UPDATE` + } + + const currentWorkspaceGrants = await database.workspaceAccessGrant.findMany({ + where: { workspaceId: { in: workspaceIds } }, + }) + const workspaceUsers = [ + ...new Set(currentWorkspaceGrants.map(({ userId: grantUserId }) => grantUserId)), + ] + const activeWorkspaceUsers = await activeUserIds(database, workspaceUsers) + for (const workspaceId of workspaceIds) { + const targetGrant = currentWorkspaceGrants.find( + (grant) => grant.workspaceId === workspaceId && grant.userId === userId, + ) + if (targetGrant?.effect !== "allow" || targetGrant.role !== "administrator") continue + const replacement = currentWorkspaceGrants.some( + (grant) => + grant.workspaceId === workspaceId && + grant.userId !== userId && + activeWorkspaceUsers.has(grant.userId) && + grant.effect === "allow" && + grant.role === "administrator", + ) + if (!replacement) { + throw new AuthenticationError( + 409, + "product_administration_orphaned", + "Transfer workspace administration before changing this membership.", + ) + } + } + + for (const prototype of prototypes) { + const userIds = [ + ...new Set([ + ...prototype.workspace.grants.map(({ userId: grantUserId }) => grantUserId), + ...prototype.grants.map(({ userId: grantUserId }) => grantUserId), + ]), + ] + const activePrototypeUsers = await activeUserIds(database, userIds) + const targetDecision = evaluatePrototypeAccess({ + workspaceGrant: prototype.workspace.grants.find((grant) => grant.userId === userId), + prototypeGrant: prototype.grants.find((grant) => grant.userId === userId), + }) + if (targetDecision.role !== "administrator") continue + const replacement = userIds.some((candidateUserId) => { + if (candidateUserId === userId || !activePrototypeUsers.has(candidateUserId)) return false + return ( + evaluatePrototypeAccess({ + workspaceGrant: prototype.workspace.grants.find( + (grant) => grant.userId === candidateUserId, + ), + prototypeGrant: prototype.grants.find((grant) => grant.userId === candidateUserId), + }).role === "administrator" + ) + }) + if (!replacement) { + throw new AuthenticationError( + 409, + "product_administration_orphaned", + "Transfer prototype administration before changing this membership.", + ) + } + } +} From 6ef473235b3ab78fbc949392f83e4acc4a8f2f74 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 18:34:48 -0700 Subject: [PATCH 13/56] fix(hub): isolate prototype list access Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/prototype-authorization.md | 4 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 169 +++++++++++++++++- .../create-service/index.ts | 140 +++++++++++++-- .../create-service/types.ts | 4 + 5 files changed, 305 insertions(+), 14 deletions(-) diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index c96f6a09..d0913471 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -37,13 +37,13 @@ The evaluator applies this order: 4. A future optional synchronized baseline may apply. 5. Otherwise Hub denies access. -Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. Prototype listing accepts the same optional baseline policy source and checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. Suspending or removing a membership fails with `product_administration_orphaned` when that user is the final active administrator for a workspace or prototype. An administrator must transfer product administration first. Changing an active installation administrator to a member does not remove an explicit product role, because installation and product authority remain separate. -A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. +A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. Workspace list responses contain only workspace fields and never embed prototype or access-rule metadata. ## API and request boundaries diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 17f5e176..7c39beb6 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -55,7 +55,7 @@ Start the PostgreSQL service as described in [Development](development.md) and s 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. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. -Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, deny-aware pagination, version ownership, archive/version races, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. 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/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 41095980..a2ea45fe 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import type { PrototypeBaselinePolicy } from "#server/domains/prototype-authorization/create-service/types" import { digestSecret } from "#server/utils/digest-secret" import type { PrismaClient } from "../../../generated/prisma/client.ts" type IdentityStore = ReturnType -async function database() { +async function database(baseline?: PrototypeBaselinePolicy) { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") onTestFinished(() => prisma.$disconnect()) @@ -16,6 +17,7 @@ async function database() { prisma, store: createIdentityStore(prisma), service: createPrototypeAuthorizationService({ + baseline, database: prisma, store: createIdentityStore(prisma), }), @@ -816,6 +818,171 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("returns scalar workspaces without inaccessible prototype or grant metadata", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "workspace-list-leak", + token: "workspace-list-leak-token", + }) + const other = await createActor(store, { + subject: "workspace-list-other", + token: "workspace-list-other-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + prototypes: { + create: { + name: "Denied prototype", + createdByUserId: other.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "deny", + }, + }, + }, + }, + }, + }) + + const page = (await service.listWorkspaces("workspace-list-leak-token", {})) as { + items: Record[] + } + + expect(page.items).toHaveLength(1) + expect(page.items[0]).toMatchObject({ + id: workspace.id, + createdByUserId: actor.user.id, + name: "Workspace", + }) + expect(page.items[0]?.createdAt).toBeInstanceOf(Date) + expect(page.items[0]?.updatedAt).toBeInstanceOf(Date) + expect(page.items[0]).not.toHaveProperty("prototypes") + expect(page.items[0]).not.toHaveProperty("grants") + expect(JSON.stringify(page)).not.toContain("Denied prototype") + }) + + it("returns an empty terminal prototype page for a prototype-only user", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "prototype-only-terminal", + token: "prototype-only-terminal-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + await expect( + service.listPrototypes("prototype-only-terminal-token", workspace.id, { + cursor: prototype.id, + limit: 20, + }), + ).resolves.toEqual({ items: [], nextCursor: null }) + }) + + it("returns an empty terminal prototype page for a baseline-authorized user", async () => { + const baselineRole = vi.fn().mockResolvedValue("viewer") + const baseline: PrototypeBaselinePolicy = { + role: baselineRole, + } + const { prisma, service, store } = await database(baseline) + const actor = await createActor(store, { + subject: "baseline-terminal", + token: "baseline-terminal-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + + await expect( + service.listPrototypes("baseline-terminal-token", workspace.id, { + cursor: prototype.id, + limit: 20, + }), + ).resolves.toEqual({ items: [], nextCursor: null }) + expect(baselineRole.mock.calls).toContainEqual([actor.user.id, prototype.id]) + }) + + it("lets a workspace deny override prototype and baseline access when listing", async () => { + const baseline: PrototypeBaselinePolicy = { + role: vi.fn().mockResolvedValue("viewer"), + } + const { prisma, service, store } = await database(baseline) + const actor = await createActor(store, { + subject: "workspace-denied-prototype-list", + token: "workspace-denied-prototype-list-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "deny", + }, + }, + }, + }) + await prisma.prototype.create({ + data: { + name: "Directly allowed prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + await expect( + service.listPrototypes("workspace-denied-prototype-list-token", workspace.id, {}), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + }) + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 727aebb1..40ba6e98 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -9,6 +9,7 @@ import { import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" import type { GrantInput, + PrototypeBaselinePolicy, PrototypeAuthorizationService, } from "#server/domains/prototype-authorization/create-service/types" import { getCurrentSession } from "#server/domains/authentication/get-current-session" @@ -263,6 +264,106 @@ function pageResult(items: T[], limit: number) { } } +function prototypeDto(prototype: { + id: string + workspaceId: string + createdByUserId: string + name: string + archivedAt: Date | null + createdAt: Date + updatedAt: Date +}) { + return prototype +} + +async function prototypeAccessDecision( + baseline: PrototypeBaselinePolicy | null, + userId: string, + workspaceGrant: GrantInput | null, + prototype: { + id: string + grants: GrantInput[] + }, +) { + return evaluatePrototypeAccess({ + workspaceGrant, + prototypeGrant: prototype.grants[0] ?? null, + baselineRole: baseline ? await baseline.role(userId, prototype.id) : null, + }) +} + +async function readAccessiblePrototypePage({ + baseline, + database, + userId, + workspaceId, + workspaceGrant, + cursor, + limit, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + workspaceId: string + workspaceGrant: GrantInput | null + cursor?: string + limit: number +}) { + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.prototype.findMany({ + where: { workspaceId }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + const decision = await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate) + if (decision.allowed) items.push(prototypeDto(candidate)) + if (items.length > limit) break + } + if (candidates.length < 100) break + } + return pageResult(items, limit) +} + +async function hasAnyPrototypeAccess({ + baseline, + database, + userId, + workspaceId, + workspaceGrant, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + workspaceId: string + workspaceGrant: GrantInput | null +}) { + if (evaluatePrototypeAccess({ workspaceGrant }).allowed) return true + let cursor: string | undefined + while (true) { + const candidates = await database.prototype.findMany({ + where: { workspaceId }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: 100, + }) + for (const candidate of candidates) { + if ((await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate)).allowed) { + return true + } + } + if (candidates.length < 100) return false + cursor = candidates.at(-1)?.id + } +} + async function audit( database: Prisma.TransactionClient, actor: Awaited>, @@ -280,9 +381,11 @@ async function audit( } export function createPrototypeAuthorizationService({ + baseline = null, database, store, }: { + baseline?: PrototypeBaselinePolicy | null database: PrismaClient store: IdentityStore }): PrototypeAuthorizationService { @@ -300,6 +403,13 @@ export function createPrototypeAuthorizationService({ orderBy: { id: "asc" }, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, + select: { + id: true, + createdByUserId: true, + name: true, + createdAt: true, + updatedAt: true, + }, }) return pageResult(items, limit) }, @@ -457,18 +567,28 @@ export function createPrototypeAuthorizationService({ async listPrototypes(sessionToken, workspaceId, query) { const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) - const { decision } = await workspaceDecision(database, actor.userId, workspaceId) - requireDecision(decision, "viewer") - const items = await database.prototype.findMany({ - where: { + const { workspace } = await workspaceDecision(database, actor.userId, workspaceId) + const workspaceGrant = workspace.grants[0] ?? null + if ( + !(await hasAnyPrototypeAccess({ + baseline, + database, + userId: actor.userId, workspaceId, - grants: { none: { userId: actor.userId, effect: "deny" } }, - }, - orderBy: { id: "asc" }, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - take: limit + 1, + workspaceGrant, + })) + ) { + throw new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied.") + } + return readAccessiblePrototypePage({ + baseline, + database, + userId: actor.userId, + workspaceId, + workspaceGrant, + cursor, + limit, }) - return pageResult(items, limit) }, async createPrototype(sessionToken, workspaceId, input) { diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index 20f044af..fc197973 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -45,3 +45,7 @@ export interface GrantInput { effect: "allow" | "deny" role: PrototypeRole | null } + +export interface PrototypeBaselinePolicy { + role(userId: string, prototypeId: string): Promise +} From ec19177ad8da86b396412201d7d0dfd6798379c4 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 19:29:49 -0700 Subject: [PATCH 14/56] fix(hub): defer baseline authorization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/prototype-authorization.md | 2 +- .../create-service/index.db.test.ts | 45 ++++++++++++++++++- .../create-service/index.ts | 7 ++- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index d0913471..31c7aaa8 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -37,7 +37,7 @@ The evaluator applies this order: 4. A future optional synchronized baseline may apply. 5. Otherwise Hub denies access. -Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. Prototype listing accepts the same optional baseline policy source and checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. Hub consults an optional baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing accepts that baseline policy source and checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index a2ea45fe..d9161391 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -940,8 +940,11 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) it("lets a workspace deny override prototype and baseline access when listing", async () => { + const baselineRole = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) const baseline: PrototypeBaselinePolicy = { - role: vi.fn().mockResolvedValue("viewer"), + role: baselineRole, } const { prisma, service, store } = await database(baseline) const actor = await createActor(store, { @@ -981,6 +984,46 @@ describe("createPrototypeAuthorizationService() database behavior", () => { status: 403, code: "prototype_access_denied", }) + expect(baselineRole.mock.calls).toHaveLength(0) + }) + + it("uses an explicit prototype role without consulting a failing baseline", async () => { + const baselineRole = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) + const { prisma, service, store } = await database({ role: baselineRole }) + const actor = await createActor(store, { + subject: "explicit-role-baseline-failure", + token: "explicit-role-baseline-failure-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + await expect( + service.listPrototypes("explicit-role-baseline-failure-token", workspace.id, {}), + ).resolves.toMatchObject({ + items: [{ id: prototype.id }], + nextCursor: null, + }) + expect(baselineRole.mock.calls).toHaveLength(0) }) it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 40ba6e98..d1fa38c1 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -285,10 +285,13 @@ async function prototypeAccessDecision( grants: GrantInput[] }, ) { - return evaluatePrototypeAccess({ + const hubDecision = evaluatePrototypeAccess({ workspaceGrant, prototypeGrant: prototype.grants[0] ?? null, - baselineRole: baseline ? await baseline.role(userId, prototype.id) : null, + }) + if (hubDecision.source !== "none" || !baseline) return hubDecision + return evaluatePrototypeAccess({ + baselineRole: await baseline.role(userId, prototype.id), }) } From c5b75af38d6e13a11f162d80a4954ff84cbc10f8 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 13:57:38 -0700 Subject: [PATCH 15/56] fix(hub): address authorization review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 530 +++++++++++++++++- .../create-service/index.ts | 112 ++-- 2 files changed, 603 insertions(+), 39 deletions(-) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index d9161391..372bc485 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -201,6 +201,174 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("rejects workspace creation with stale verification without writing product state", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "stale-workspace-creator", + token: "stale-workspace-creator-token", + role: "administrator", + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + + await expect( + service.createWorkspace("stale-workspace-creator-token", { name: "Rejected workspace" }), + ).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + expect(await prisma.workspace.count()).toBe(0) + expect(await prisma.workspaceAccessGrant.count()).toBe(0) + expect(await prisma.securityAudit.count()).toBe(0) + }) + + it("rejects workspace creation by a member without writing product state", async () => { + const { prisma, service, store } = await database() + await createActor(store, { + subject: "member-workspace-creator", + token: "member-workspace-creator-token", + }) + + await expect( + service.createWorkspace("member-workspace-creator-token", { name: "Rejected workspace" }), + ).rejects.toMatchObject({ + status: 403, + code: "installation_administrator_required", + }) + expect(await prisma.workspace.count()).toBe(0) + expect(await prisma.workspaceAccessGrant.count()).toBe(0) + expect(await prisma.securityAudit.count()).toBe(0) + }) + + it("rejects prototype creation by a workspace viewer without writing product state", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "viewer-prototype-creator", + token: "viewer-prototype-creator-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + await expect( + service.createPrototype("viewer-prototype-creator-token", workspace.id, { + name: "Rejected prototype", + }), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect(await prisma.prototype.count()).toBe(0) + expect(await prisma.securityAudit.count()).toBe(0) + }) + + it.each([ + { scope: "workspace", action: "change" }, + { scope: "workspace", action: "remove" }, + { scope: "prototype", action: "change" }, + { scope: "prototype", action: "remove" }, + ] as const)( + "rejects stale verification for a $scope access $action without changing access or audits", + async ({ scope, action }) => { + const { prisma, service, store } = await database() + const token = `stale-${scope}-${action}-actor-token` + const actor = await createActor(store, { + subject: `stale-${scope}-${action}-actor`, + token, + role: "administrator", + }) + const target = await createActor(store, { + subject: `stale-${scope}-${action}-target`, + token: `stale-${scope}-${action}-target-token`, + }) + const workspace = (await service.createWorkspace(token, { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype(token, workspace.id, { + name: "Prototype", + })) as { id: string } + await prisma.workspaceAccessGrant.create({ + data: { + workspaceId: workspace.id, + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: prototype.id, + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + + const operation = + scope === "workspace" + ? action === "change" + ? service.setWorkspaceAccess(token, workspace.id, target.user.id, { + effect: "allow", + role: "commenter", + }) + : service.removeWorkspaceAccess(token, workspace.id, target.user.id) + : action === "change" + ? service.setPrototypeAccess(token, prototype.id, target.user.id, { + effect: "allow", + role: "commenter", + }) + : service.removePrototypeAccess(token, prototype.id, target.user.id) + + await expect(operation).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: target.user.id, + }, + }, + select: { effect: true, role: true }, + }), + ).toEqual({ effect: "allow", role: "viewer" }) + expect( + await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: target.user.id, + }, + }, + select: { effect: true, role: true }, + }), + ).toEqual({ effect: "allow", role: "viewer" }) + expect( + await prisma.securityAudit.count({ + where: { targetUserId: target.user.id }, + }), + ).toBe(0) + }, + ) + it("isolates grants and immutable versions by prototype", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { subject: "isolated", token: "isolated-token" }) @@ -544,6 +712,105 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it.each([ + { scope: "workspace", action: "change" }, + { scope: "workspace", action: "remove" }, + { scope: "prototype", action: "change" }, + { scope: "prototype", action: "remove" }, + ] as const)( + "does not let an uppercase UUID bypass the final $scope administrator check on $action", + async ({ scope, action }) => { + const { prisma, service, store } = await database() + const token = `uppercase-${scope}-${action}-token` + const actor = await createActor(store, { + subject: `uppercase-${scope}-${action}`, + token, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: scope === "workspace" ? "administrator" : "viewer", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: + scope === "prototype" + ? { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + } + : undefined, + }, + }) + const uppercaseUserId = actor.user.id.toUpperCase() + + const operation = + scope === "workspace" + ? action === "change" + ? service.setWorkspaceAccess(token, workspace.id, uppercaseUserId, { + effect: "allow", + role: "viewer", + }) + : service.removeWorkspaceAccess(token, workspace.id, uppercaseUserId) + : action === "change" + ? service.setPrototypeAccess(token, prototype.id, uppercaseUserId, { + effect: "allow", + role: "viewer", + }) + : service.removePrototypeAccess(token, prototype.id, uppercaseUserId) + + await expect(operation).rejects.toMatchObject({ + status: 409, + code: + scope === "workspace" ? "last_workspace_administrator" : "last_prototype_administrator", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: actor.user.id, + }, + }, + select: { effect: true, role: true }, + }), + ).toEqual({ + effect: "allow", + role: scope === "workspace" ? "administrator" : "viewer", + }) + expect( + await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: actor.user.id, + }, + }, + select: { effect: true, role: true }, + }), + ).toEqual(scope === "prototype" ? { effect: "allow", role: "administrator" } : null) + expect( + await prisma.securityAudit.count({ + where: { event: { startsWith: `${scope}.access.` } }, + }), + ).toBe(0) + }, + ) + it("commits one valid rule and an audit for each successful concurrent access change", async () => { const { prisma, service, store } = await database() const administrator = await createActor(store, { @@ -939,6 +1206,171 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(baselineRole.mock.calls).toContainEqual([actor.user.id, prototype.id]) }) + it("uses baseline authorization for single-prototype reads and role-gated mutations", async () => { + const baselineRole = vi.fn().mockResolvedValue("administrator") + const { prisma, service, store } = await database({ role: baselineRole }) + const actor = await createActor(store, { + subject: "baseline-single-prototype", + token: "baseline-single-prototype-token", + }) + const owner = await createActor(store, { + subject: "baseline-single-prototype-owner", + token: "baseline-single-prototype-owner-token", + }) + const target = await createActor(store, { + subject: "baseline-single-prototype-target", + token: "baseline-single-prototype-target-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: owner.user.id, + grants: { + create: { + userId: owner.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: owner.user.id, + }, + }) + const firstVersion = await prisma.prototypeVersion.create({ + data: { + prototypeId: prototype.id, + createdByUserId: owner.user.id, + sequence: 1, + label: "First", + }, + }) + + await expect( + service.getPrototype("baseline-single-prototype-token", prototype.id), + ).resolves.toMatchObject({ id: prototype.id }) + await expect( + service.explainPrototypeAccess("baseline-single-prototype-token", prototype.id), + ).resolves.toMatchObject({ + allowed: true, + role: "administrator", + source: "baseline", + }) + await expect( + service.listVersions("baseline-single-prototype-token", prototype.id, {}), + ).resolves.toMatchObject({ items: [{ id: firstVersion.id }] }) + await expect( + service.getVersion("baseline-single-prototype-token", prototype.id, firstVersion.id), + ).resolves.toMatchObject({ id: firstVersion.id }) + await expect( + service.createVersion("baseline-single-prototype-token", prototype.id, { + label: "Second", + }), + ).resolves.toMatchObject({ sequence: 2 }) + await expect( + service.setPrototypeAccess("baseline-single-prototype-token", prototype.id, target.user.id, { + effect: "allow", + role: "viewer", + }), + ).resolves.toMatchObject({ role: "viewer" }) + await expect( + service.removePrototypeAccess( + "baseline-single-prototype-token", + prototype.id, + target.user.id, + ), + ).resolves.toBeUndefined() + const archived = (await service.archivePrototype( + "baseline-single-prototype-token", + prototype.id, + )) as { archivedAt: Date | null } + expect(archived.archivedAt).toBeInstanceOf(Date) + + expect(baselineRole.mock.calls.length).toBeGreaterThanOrEqual(8) + expect( + baselineRole.mock.calls.every( + ([userId, prototypeId]) => userId === actor.user.id && prototypeId === prototype.id, + ), + ).toBe(true) + expect( + await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: target.user.id, + }, + }, + }), + ).toBeNull() + }) + + it("short-circuits the baseline for explicit prototype roles and denies", async () => { + const baselineRole = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) + const { prisma, service, store } = await database({ role: baselineRole }) + const actor = await createActor(store, { + subject: "single-prototype-short-circuit", + token: "single-prototype-short-circuit-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const [allowed, denied] = await Promise.all([ + prisma.prototype.create({ + data: { + name: "Allowed", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }), + prisma.prototype.create({ + data: { + name: "Denied", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "deny", + }, + }, + }, + }), + ]) + + await expect( + service.getPrototype("single-prototype-short-circuit-token", allowed.id), + ).resolves.toMatchObject({ id: allowed.id }) + await expect( + service.getPrototype("single-prototype-short-circuit-token", denied.id), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + await expect( + service.explainPrototypeAccess("single-prototype-short-circuit-token", denied.id), + ).resolves.toMatchObject({ + allowed: false, + source: "hub-prototype", + }) + expect(baselineRole).not.toHaveBeenCalled() + }) + it("lets a workspace deny override prototype and baseline access when listing", async () => { const baselineRole = vi .fn() @@ -977,6 +1409,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }, }, }) + const prototypeScan = vi.spyOn(prisma.prototype, "findMany") await expect( service.listPrototypes("workspace-denied-prototype-list-token", workspace.id, {}), @@ -984,6 +1417,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { status: 403, code: "prototype_access_denied", }) + expect(prototypeScan).not.toHaveBeenCalled() expect(baselineRole.mock.calls).toHaveLength(0) }) @@ -1017,12 +1451,26 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }, }) - await expect( - service.listPrototypes("explicit-role-baseline-failure-token", workspace.id, {}), - ).resolves.toMatchObject({ + const page = (await service.listPrototypes( + "explicit-role-baseline-failure-token", + workspace.id, + {}, + )) as { items: Record[]; nextCursor: string | null } + expect(page).toMatchObject({ items: [{ id: prototype.id }], nextCursor: null, }) + expect(Object.keys(page.items[0] ?? {}).sort()).toEqual( + [ + "archivedAt", + "createdAt", + "createdByUserId", + "id", + "name", + "updatedAt", + "workspaceId", + ].sort(), + ) expect(baselineRole.mock.calls).toHaveLength(0) }) @@ -1084,6 +1532,82 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(new Set([...first.items, ...second.items].map(({ id }) => id)).size).toBe(4) }) + it("lists immutable versions by sequence and paginates in that order", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "version-sequence-pagination", + token: "version-sequence-pagination-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + const versions = [ + { + id: "ffffffff-ffff-4fff-8fff-ffffffffffff", + sequence: 1, + label: "First immutable version", + }, + { + id: "00000000-0000-4000-8000-000000000001", + sequence: 2, + label: "Second immutable version", + }, + { + id: "11111111-1111-4111-8111-111111111111", + sequence: 3, + label: "Third immutable version", + }, + ] + await prisma.prototypeVersion.createMany({ + data: versions.map((version) => ({ + ...version, + prototypeId: prototype.id, + createdByUserId: actor.user.id, + })), + }) + + const first = (await service.listVersions("version-sequence-pagination-token", prototype.id, { + limit: 2, + })) as { + items: { id: string; sequence: number; label: string | null }[] + nextCursor: string | null + } + const second = (await service.listVersions("version-sequence-pagination-token", prototype.id, { + limit: 2, + cursor: first.nextCursor, + })) as { + items: { id: string; sequence: number; label: string | null }[] + nextCursor: string | null + } + + expect(first.items.map(({ sequence, label }) => ({ sequence, label }))).toEqual([ + { sequence: 1, label: "First immutable version" }, + { sequence: 2, label: "Second immutable version" }, + ]) + expect(first.nextCursor).toBe(versions[1]?.id) + expect(second.items.map(({ sequence, label }) => ({ sequence, label }))).toEqual([ + { sequence: 3, label: "Third immutable version" }, + ]) + expect(second.nextCursor).toBeNull() + }) + it("creates sequential immutable versions and rejects additions after archival", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index d1fa38c1..01e110c1 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -79,6 +79,11 @@ function requireIdentifier(value: string, code: string, message: string): void { } } +function normalizeIdentifier(value: string, code: string, message: string): string { + requireIdentifier(value, code, message) + return value.toLowerCase() +} + function requireDecision(decision: AccessDecision, required: PrototypeRole): void { if (!decision.allowed || !hasRole(decision.role, required)) { throw new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied.") @@ -164,6 +169,7 @@ async function workspaceDecision( } async function prototypeDecision( + baseline: PrototypeBaselinePolicy | null, database: Prisma.TransactionClient, userId: string, prototypeId: string, @@ -181,10 +187,12 @@ async function prototypeDecision( } return { prototype, - decision: evaluatePrototypeAccess({ - workspaceGrant: prototype.workspace.grants[0] ?? null, - prototypeGrant: prototype.grants[0] ?? null, - }), + decision: await prototypeAccessDecision( + baseline, + userId, + prototype.workspace.grants[0] ?? null, + prototype, + ), } } @@ -273,7 +281,15 @@ function prototypeDto(prototype: { createdAt: Date updatedAt: Date }) { - return prototype + return { + id: prototype.id, + workspaceId: prototype.workspaceId, + createdByUserId: prototype.createdByUserId, + name: prototype.name, + archivedAt: prototype.archivedAt, + createdAt: prototype.createdAt, + updatedAt: prototype.updatedAt, + } } async function prototypeAccessDecision( @@ -347,7 +363,8 @@ async function hasAnyPrototypeAccess({ workspaceId: string workspaceGrant: GrantInput | null }) { - if (evaluatePrototypeAccess({ workspaceGrant }).allowed) return true + const workspaceDecision = evaluatePrototypeAccess({ workspaceGrant }) + if (workspaceDecision.source !== "none") return workspaceDecision.allowed let cursor: string | undefined while (true) { const candidates = await database.prototype.findMany({ @@ -466,6 +483,11 @@ export function createPrototypeAuthorizationService({ async setWorkspaceAccess(sessionToken, workspaceId, targetUserId, input) { const change = parseGrant(input) + const targetId = normalizeIdentifier( + targetUserId, + "target_user_inactive", + "The target user is not active.", + ) return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) @@ -473,11 +495,9 @@ export function createPrototypeAuthorizationService({ await lockWorkspace(transaction, workspaceId) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "administrator") - await requireActiveTarget(transaction, targetUserId) + await requireActiveTarget(transaction, targetId) const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) - if ( - !(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetUserId, change)) - ) { + if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetId, change))) { throw new AuthenticationError( 409, "last_workspace_administrator", @@ -489,7 +509,7 @@ export function createPrototypeAuthorizationService({ !(await hasPrototypeAdministratorAfter( transaction, prototypeId, - targetUserId, + targetId, change, "workspace", )) @@ -502,14 +522,14 @@ export function createPrototypeAuthorizationService({ } } const grant = await transaction.workspaceAccessGrant.upsert({ - where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, - create: { workspaceId, userId: targetUserId, ...change }, + where: { workspaceId_userId: { workspaceId, userId: targetId } }, + create: { workspaceId, userId: targetId, ...change }, update: change, }) await audit(transaction, actor, { event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", workspaceId, - targetUserId, + targetUserId: targetId, context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, }) return grant @@ -517,6 +537,11 @@ export function createPrototypeAuthorizationService({ }, async removeWorkspaceAccess(sessionToken, workspaceId, targetUserId) { + const targetId = normalizeIdentifier( + targetUserId, + "access_not_found", + "The access rule was not found.", + ) await store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) @@ -524,15 +549,14 @@ export function createPrototypeAuthorizationService({ await lockWorkspace(transaction, workspaceId) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "administrator") - requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) const existing = await transaction.workspaceAccessGrant.findUnique({ - where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + where: { workspaceId_userId: { workspaceId, userId: targetId } }, }) if (!existing) { throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") } - if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetUserId, null))) { + if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetId, null))) { throw new AuthenticationError( 409, "last_workspace_administrator", @@ -544,7 +568,7 @@ export function createPrototypeAuthorizationService({ !(await hasPrototypeAdministratorAfter( transaction, prototypeId, - targetUserId, + targetId, null, "workspace", )) @@ -557,12 +581,12 @@ export function createPrototypeAuthorizationService({ } } await transaction.workspaceAccessGrant.delete({ - where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + where: { workspaceId_userId: { workspaceId, userId: targetId } }, }) await audit(transaction, actor, { event: "workspace.access.remove", workspaceId, - targetUserId, + targetUserId: targetId, }) }) }, @@ -614,7 +638,12 @@ export function createPrototypeAuthorizationService({ async getPrototype(sessionToken, prototypeId) { const actor = await currentSession(store, sessionToken) - const { prototype, decision } = await prototypeDecision(database, actor.userId, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + database, + actor.userId, + prototypeId, + ) requireDecision(decision, "viewer") return { id: prototype.id, @@ -633,6 +662,7 @@ export function createPrototypeAuthorizationService({ requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, @@ -660,27 +690,31 @@ export function createPrototypeAuthorizationService({ async explainPrototypeAccess(sessionToken, prototypeId) { const actor = await currentSession(store, sessionToken) - const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) return decision }, async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { const change = parseGrant(input) + const targetId = normalizeIdentifier( + targetUserId, + "target_user_inactive", + "The target user is not active.", + ) return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, ) requireDecision(decision, "administrator") - await requireActiveTarget(transaction, targetUserId) - if ( - !(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetUserId, change)) - ) { + await requireActiveTarget(transaction, targetId) + if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, change))) { throw new AuthenticationError( 409, "last_prototype_administrator", @@ -688,15 +722,15 @@ export function createPrototypeAuthorizationService({ ) } const grant = await transaction.prototypeAccessGrant.upsert({ - where: { prototypeId_userId: { prototypeId, userId: targetUserId } }, - create: { prototypeId, userId: targetUserId, ...change }, + where: { prototypeId_userId: { prototypeId, userId: targetId } }, + create: { prototypeId, userId: targetId, ...change }, update: change, }) await audit(transaction, actor, { event: change.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant", workspaceId: prototype.workspaceId, prototypeId, - targetUserId, + targetUserId: targetId, context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, }) return grant @@ -704,19 +738,24 @@ export function createPrototypeAuthorizationService({ }, async removePrototypeAccess(sessionToken, prototypeId, targetUserId) { + const targetId = normalizeIdentifier( + targetUserId, + "access_not_found", + "The access rule was not found.", + ) await store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, ) requireDecision(decision, "administrator") - requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") - if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetUserId, null))) { + if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, null))) { throw new AuthenticationError( 409, "last_prototype_administrator", @@ -724,7 +763,7 @@ export function createPrototypeAuthorizationService({ ) } const removed = await transaction.prototypeAccessGrant.deleteMany({ - where: { prototypeId, userId: targetUserId }, + where: { prototypeId, userId: targetId }, }) if (removed.count === 0) { throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") @@ -733,7 +772,7 @@ export function createPrototypeAuthorizationService({ event: "prototype.access.remove", workspaceId: prototype.workspaceId, prototypeId, - targetUserId, + targetUserId: targetId, }) }) }, @@ -741,11 +780,11 @@ export function createPrototypeAuthorizationService({ async listVersions(sessionToken, prototypeId, query) { const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) - const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) requireDecision(decision, "viewer") const items = await database.prototypeVersion.findMany({ where: { prototypeId }, - orderBy: { id: "asc" }, + orderBy: { sequence: "asc" }, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, }) @@ -762,6 +801,7 @@ export function createPrototypeAuthorizationService({ requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, @@ -798,7 +838,7 @@ export function createPrototypeAuthorizationService({ async getVersion(sessionToken, prototypeId, versionId) { const actor = await currentSession(store, sessionToken) - const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) requireDecision(decision, "viewer") requireIdentifier(versionId, "version_not_found", "The version was not found.") const version = await database.prototypeVersion.findFirst({ From 530a1d56751e26cf52b0d8deda64959fd7c8cfb7 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 15:47:31 -0700 Subject: [PATCH 16/56] fix(hub): bound authorization pagination Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 120 ++++++++++++++++++ .../create-service/index.ts | 34 ++++- 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 372bc485..e7cbe622 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -369,6 +369,77 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }, ) + it.each([ + { scope: "workspace", targetStatus: "unknown" }, + { scope: "workspace", targetStatus: "suspended" }, + { scope: "workspace", targetStatus: "removed" }, + { scope: "prototype", targetStatus: "unknown" }, + { scope: "prototype", targetStatus: "suspended" }, + { scope: "prototype", targetStatus: "removed" }, + ] as const)( + "rejects a $targetStatus target for a $scope grant without writing access or audits", + async ({ scope, targetStatus }) => { + const { prisma, service, store } = await database() + const token = `inactive-${scope}-${targetStatus}-actor-token` + await createActor(store, { + subject: `inactive-${scope}-${targetStatus}-actor`, + token, + role: "administrator", + }) + const workspace = (await service.createWorkspace(token, { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype(token, workspace.id, { + name: "Prototype", + })) as { id: string } + const targetUserId = + targetStatus === "unknown" + ? "00000000-0000-4000-8000-000000000099" + : ( + await createActor(store, { + subject: `inactive-${scope}-${targetStatus}-target`, + token: `inactive-${scope}-${targetStatus}-target-token`, + }) + ).user.id + if (targetStatus !== "unknown") { + await prisma.installationMembership.update({ + where: { userId: targetUserId }, + data: + targetStatus === "suspended" + ? { status: "suspended", suspendedAt: new Date(), removedAt: null } + : { status: "removed", suspendedAt: null, removedAt: new Date() }, + }) + } + + const operation = + scope === "workspace" + ? service.setWorkspaceAccess(token, workspace.id, targetUserId, { + effect: "allow", + role: "viewer", + }) + : service.setPrototypeAccess(token, prototype.id, targetUserId, { + effect: "allow", + role: "viewer", + }) + + await expect(operation).rejects.toMatchObject({ + status: 422, + code: "target_user_inactive", + }) + expect( + await prisma.workspaceAccessGrant.count({ + where: { workspaceId: workspace.id, userId: targetUserId }, + }), + ).toBe(0) + expect( + await prisma.prototypeAccessGrant.count({ + where: { prototypeId: prototype.id, userId: targetUserId }, + }), + ).toBe(0) + expect(await prisma.securityAudit.count({ where: { targetUserId } })).toBe(0) + }, + ) + it("isolates grants and immutable versions by prototype", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { subject: "isolated", token: "isolated-token" }) @@ -1085,6 +1156,55 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("continues workspace pagination after access to the prior cursor is revoked", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "workspace-revoked-cursor", + token: "workspace-revoked-cursor-token", + }) + const workspaceIds = [ + "00000000-0000-4000-8000-000000000011", + "00000000-0000-4000-8000-000000000012", + "00000000-0000-4000-8000-000000000013", + ] + for (const id of workspaceIds) { + await prisma.workspace.create({ + data: { + id, + name: `Workspace ${id.at(-1)}`, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + } + + const first = (await service.listWorkspaces("workspace-revoked-cursor-token", { + limit: 1, + })) as { items: { id: string }[]; nextCursor: string | null } + await prisma.workspaceAccessGrant.delete({ + where: { + workspaceId_userId: { + workspaceId: workspaceIds[0]!, + userId: actor.user.id, + }, + }, + }) + const second = (await service.listWorkspaces("workspace-revoked-cursor-token", { + limit: 1, + cursor: first.nextCursor, + })) as { items: { id: string }[]; nextCursor: string | null } + + expect(first.items.map(({ id }) => id)).toEqual([workspaceIds[0]]) + expect(second.items.map(({ id }) => id)).toEqual([workspaceIds[1]]) + expect(second.nextCursor).toBe(workspaceIds[1]) + }) + it("returns scalar workspaces without inaccessible prototype or grant metadata", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 01e110c1..c24e7ebb 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -328,14 +328,28 @@ async function readAccessiblePrototypePage({ cursor?: string limit: number }) { + if (!baseline) { + const accessWhere = workspaceGrant + ? { grants: { none: { userId, effect: "deny" as const } } } + : { grants: { some: { userId, effect: "allow" as const } } } + const items = await database.prototype.findMany({ + where: { + workspaceId, + ...(cursor ? { id: { gt: cursor } } : {}), + ...accessWhere, + }, + orderBy: { id: "asc" }, + take: limit + 1, + }) + return pageResult(items.map(prototypeDto), limit) + } const items = [] let scanCursor = cursor while (items.length <= limit) { const candidates = await database.prototype.findMany({ - where: { workspaceId }, + where: { workspaceId, ...(scanCursor ? { id: { gt: scanCursor } } : {}) }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, - ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), take: 100, }) if (candidates.length === 0) break @@ -365,13 +379,23 @@ async function hasAnyPrototypeAccess({ }) { const workspaceDecision = evaluatePrototypeAccess({ workspaceGrant }) if (workspaceDecision.source !== "none") return workspaceDecision.allowed + if (!baseline) { + return Boolean( + await database.prototype.findFirst({ + where: { + workspaceId, + grants: { some: { userId, effect: "allow" } }, + }, + select: { id: true }, + }), + ) + } let cursor: string | undefined while (true) { const candidates = await database.prototype.findMany({ - where: { workspaceId }, + where: { workspaceId, ...(cursor ? { id: { gt: cursor } } : {}) }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: 100, }) for (const candidate of candidates) { @@ -415,13 +439,13 @@ export function createPrototypeAuthorizationService({ const actor = await currentSession(store, sessionToken) const items = await database.workspace.findMany({ where: { + ...(cursor ? { id: { gt: cursor } } : {}), grants: { some: { userId: actor.userId, effect: "allow" }, none: { userId: actor.userId, effect: "deny" }, }, }, orderBy: { id: "asc" }, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, select: { id: true, From 1127b27e40378441705022d23ca3c7e04d87132a Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 04:39:37 -0700 Subject: [PATCH 17/56] test(hub): cover authorization boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 226 ++++++++++++++++++ hub/server/routes/product/index.test.ts | 124 ++++++++++ 2 files changed, 350 insertions(+) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index e7cbe622..1d1ec9b7 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -274,6 +274,153 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(await prisma.securityAudit.count()).toBe(0) }) + it.each([ + { scope: "workspace", action: "change" }, + { scope: "workspace", action: "remove" }, + { scope: "prototype", action: "change" }, + { scope: "prototype", action: "remove" }, + ] as const)( + "rejects an editor attempting to $action $scope access without changing grants or audits", + async ({ scope, action }) => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: `editor-${scope}-${action}`, + token: `editor-${scope}-${action}-token`, + }) + const target = await createActor(store, { + subject: `editor-${scope}-${action}-target`, + token: `editor-${scope}-${action}-target-token`, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: [ + { userId: actor.user.id, effect: "allow", role: "editor" }, + { userId: target.user.id, effect: "allow", role: "viewer" }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + const operation = + scope === "workspace" + ? action === "change" + ? service.setWorkspaceAccess( + `editor-${scope}-${action}-token`, + workspace.id, + target.user.id, + { effect: "allow", role: "commenter" }, + ) + : service.removeWorkspaceAccess( + `editor-${scope}-${action}-token`, + workspace.id, + target.user.id, + ) + : action === "change" + ? service.setPrototypeAccess( + `editor-${scope}-${action}-token`, + prototype.id, + target.user.id, + { effect: "allow", role: "commenter" }, + ) + : service.removePrototypeAccess( + `editor-${scope}-${action}-token`, + prototype.id, + target.user.id, + ) + + await expect(operation).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect( + await prisma.workspaceAccessGrant.findMany({ + where: { workspaceId: workspace.id }, + orderBy: { userId: "asc" }, + select: { userId: true, effect: true, role: true }, + }), + ).toEqual( + [ + { userId: actor.user.id, effect: "allow", role: "editor" }, + { userId: target.user.id, effect: "allow", role: "viewer" }, + ].sort((left, right) => left.userId.localeCompare(right.userId)), + ) + expect( + await prisma.prototypeAccessGrant.findMany({ + where: { prototypeId: prototype.id }, + select: { userId: true, effect: true, role: true }, + }), + ).toEqual([{ userId: target.user.id, effect: "allow", role: "viewer" }]) + expect(await prisma.securityAudit.count()).toBe(0) + }, + ) + + it.each([{ action: "archive" }, { action: "create a version" }] as const)( + "rejects a commenter attempting to $action without changing product state or audits", + async ({ action }) => { + const { prisma, service, store } = await database() + const token = `commenter-${action.replaceAll(" ", "-")}-token` + const actor = await createActor(store, { + subject: `commenter-${action.replaceAll(" ", "-")}`, + token, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "commenter", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + + const operation = + action === "archive" + ? service.archivePrototype(token, prototype.id) + : service.createVersion(token, prototype.id, { label: "Rejected version" }) + + await expect(operation).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect( + await prisma.prototype.findUnique({ + where: { id: prototype.id }, + select: { archivedAt: true }, + }), + ).toEqual({ archivedAt: null }) + expect(await prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } })).toBe(0) + expect(await prisma.securityAudit.count()).toBe(0) + }, + ) + it.each([ { scope: "workspace", action: "change" }, { scope: "workspace", action: "remove" }, @@ -614,6 +761,85 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ).toEqual([{ event: "workspace.access.remove", targetUserId: first.user.id }]) }) + it.each([{ action: "downgrade" }, { action: "remove" }] as const)( + "rejects a workspace administrator $action that would orphan a child prototype", + async ({ action }) => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: `child-guard-${action}-actor`, + token: `child-guard-${action}-actor-token`, + }) + const target = await createActor(store, { + subject: `child-guard-${action}-target`, + token: `child-guard-${action}-target-token`, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: [ + { userId: actor.user.id, effect: "allow", role: "administrator" }, + { userId: target.user.id, effect: "allow", role: "administrator" }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Child prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + const operation = + action === "downgrade" + ? service.setWorkspaceAccess( + `child-guard-${action}-actor-token`, + workspace.id, + target.user.id, + { effect: "allow", role: "editor" }, + ) + : service.removeWorkspaceAccess( + `child-guard-${action}-actor-token`, + workspace.id, + target.user.id, + ) + + await expect(operation).rejects.toMatchObject({ + status: 409, + code: "last_prototype_administrator", + }) + expect( + await prisma.workspaceAccessGrant.findMany({ + where: { workspaceId: workspace.id }, + orderBy: { userId: "asc" }, + select: { userId: true, effect: true, role: true }, + }), + ).toEqual( + [ + { userId: actor.user.id, effect: "allow", role: "administrator" }, + { userId: target.user.id, effect: "allow", role: "administrator" }, + ].sort((left, right) => left.userId.localeCompare(right.userId)), + ) + expect( + await prisma.prototypeAccessGrant.findMany({ + where: { prototypeId: prototype.id }, + select: { userId: true, effect: true, role: true }, + }), + ).toEqual([{ userId: actor.user.id, effect: "allow", role: "viewer" }]) + expect(await prisma.securityAudit.count()).toBe(0) + }, + ) + it("lets exactly one prototype-direct administrator relinquish authority", async () => { const { prisma, service, store } = await database() const first = await createActor(store, { diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts index a8e195d0..bc77fd73 100644 --- a/hub/server/routes/product/index.test.ts +++ b/hub/server/routes/product/index.test.ts @@ -76,6 +76,130 @@ async function serve(authentication: AuthenticationRoutes, logger = createLogger } describe("product routes", () => { + it.each([ + { + method: "GET", + path: "/api/workspaces?cursor=workspace-cursor&limit=11", + call: "listWorkspaces", + expectedArguments: [sessionToken, { cursor: "workspace-cursor", limit: "11" }], + }, + { + method: "POST", + path: "/api/workspaces", + call: "createWorkspace", + body: { name: "Mapped workspace" }, + expectedArguments: [sessionToken, { name: "Mapped workspace" }], + }, + { + method: "GET", + path: "/api/workspaces/workspace-123", + call: "getWorkspace", + expectedArguments: [sessionToken, "workspace-123"], + }, + { + method: "PUT", + path: "/api/workspaces/workspace-123/access/user-789", + call: "setWorkspaceAccess", + body: { effect: "allow", role: "commenter" }, + expectedArguments: [ + sessionToken, + "workspace-123", + "user-789", + { effect: "allow", role: "commenter" }, + ], + }, + { + method: "DELETE", + path: "/api/workspaces/workspace-123/access/user-789", + call: "removeWorkspaceAccess", + expectedArguments: [sessionToken, "workspace-123", "user-789"], + }, + { + method: "GET", + path: "/api/workspaces/workspace-123/prototypes?cursor=prototype-cursor&limit=22", + call: "listPrototypes", + expectedArguments: [ + sessionToken, + "workspace-123", + { cursor: "prototype-cursor", limit: "22" }, + ], + }, + { + method: "POST", + path: "/api/workspaces/workspace-123/prototypes", + call: "createPrototype", + body: { name: "Mapped prototype" }, + expectedArguments: [sessionToken, "workspace-123", { name: "Mapped prototype" }], + }, + { + method: "GET", + path: "/api/prototypes/prototype-456", + call: "getPrototype", + expectedArguments: [sessionToken, "prototype-456"], + }, + { + method: "POST", + path: "/api/prototypes/prototype-456/archive", + call: "archivePrototype", + expectedArguments: [sessionToken, "prototype-456"], + }, + { + method: "GET", + path: "/api/prototypes/prototype-456/access/explanation", + call: "explainPrototypeAccess", + expectedArguments: [sessionToken, "prototype-456"], + }, + { + method: "PUT", + path: "/api/prototypes/prototype-456/access/user-789", + call: "setPrototypeAccess", + body: { effect: "deny" }, + expectedArguments: [sessionToken, "prototype-456", "user-789", { effect: "deny" }], + }, + { + method: "DELETE", + path: "/api/prototypes/prototype-456/access/user-789", + call: "removePrototypeAccess", + expectedArguments: [sessionToken, "prototype-456", "user-789"], + }, + { + method: "GET", + path: "/api/prototypes/prototype-456/versions?cursor=version-cursor&limit=33", + call: "listVersions", + expectedArguments: [sessionToken, "prototype-456", { cursor: "version-cursor", limit: "33" }], + }, + { + method: "POST", + path: "/api/prototypes/prototype-456/versions", + call: "createVersion", + body: { label: "Mapped version" }, + expectedArguments: [sessionToken, "prototype-456", { label: "Mapped version" }], + }, + { + method: "GET", + path: "/api/prototypes/prototype-456/versions/version-012", + call: "getVersion", + expectedArguments: [sessionToken, "prototype-456", "version-012"], + }, + ] as const)( + "maps $method $path to $call with the exact request values", + async ({ method, path, call, body, expectedArguments }) => { + const authentication = runtime({ + [call]: vi.fn().mockResolvedValue({}), + }) + const baseUrl = await serve(authentication) + const mutation = method !== "GET" + + await fetch(`${baseUrl}${path}`, { + method, + headers: mutation ? protectedHeaders : { cookie: `__Host-replica-session=${sessionToken}` }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + + expect(authentication.prototypeAuthorization[call].mock.calls).toEqual([expectedArguments]) + }, + ) + it.each([ { method: "GET", path: "/api/workspaces", call: "listWorkspaces" }, { method: "POST", path: "/api/workspaces", call: "createWorkspace", body: { name: "W" } }, From 028966a4e50645259d2ab41ef649e34651bd3402 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 07:52:22 -0700 Subject: [PATCH 18/56] fix(hub): scope authorization cursors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/prototype-authorization.md | 2 +- .../create-service/index.db.test.ts | 158 +++++++++++++++++ .../create-service/index.db.test.ts | 165 ++++++++++++++++++ .../create-service/index.ts | 32 ++++ 4 files changed, 356 insertions(+), 1 deletion(-) diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 31c7aaa8..83c7bf01 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -60,7 +60,7 @@ The product API uses: - `GET` and `POST /api/prototypes/:prototypeId/versions` - `GET /api/prototypes/:prototypeId/versions/:versionId` -List routes use an opaque UUID cursor and a limit from 1 through 100. Invalid pagination returns an explicit error. State-changing requests require the configured origin and matching CSRF cookie and header. Product responses are private and non-cacheable, request payloads are omitted from logs, and mutation routes use a bounded rate limit. +List routes use an opaque UUID cursor and a limit from 1 through 100. Prototype and version cursors belong to their owning workspace or prototype; a cursor from another scope is invalid. Invalid pagination returns `422 invalid_pagination`, while malformed or missing path resources return their resource-specific `404` error. State-changing requests require the configured origin and matching CSRF cookie and header. Product responses are private and non-cacheable, request payloads are omitted from logs, and mutation routes use a bounded rate limit. Every product route restores the current session and active installation membership before evaluating product access. Workspace creation and direct access changes also require identity verification from the last five minutes. Mutations repeat the membership check inside their database transaction so a suspended or removed actor cannot complete a stale request. diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index 8027feb6..93ef76d1 100644 --- a/hub/server/domains/administration/create-service/index.db.test.ts +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -270,4 +270,162 @@ describe("createAdministrationService() database behavior", () => { }, ]) }) + + it("does not count a workspace administrator denied on the prototype as a replacement", async () => { + const { prisma, store } = await database() + const actor = await createAdministrator(store, "denied-actor", "denied-actor-token") + const target = await createAdministrator(store, "denied-target", "denied-target-token") + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: target.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "deny", + }, + { + userId: target.user.id, + effect: "allow", + role: "administrator", + }, + ], + }, + }, + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("denied-actor-token", target.membership.id, { + status: "suspended", + }), + ).rejects.toMatchObject({ + status: 409, + code: "product_administration_orphaned", + message: "Transfer prototype administration before changing this membership.", + }) + + await prisma.prototypeAccessGrant.delete({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: actor.user.id, + }, + }, + }) + await expect( + service.changeMembership("denied-actor-token", target.membership.id, { + status: "suspended", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + status: "suspended", + }) + }) + + it("requires active replacements for every prototype a removed member administers", async () => { + const { prisma, store } = await database() + const actor = await createAdministrator(store, "multi-scope-actor", "multi-scope-actor-token") + const target = await createAdministrator( + store, + "multi-scope-target", + "multi-scope-target-token", + ) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + { + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + ], + }, + }, + }) + const prototypes = await Promise.all( + ["First prototype", "Second prototype"].map((name, index) => + prisma.prototype.create({ + data: { + name, + workspaceId: workspace.id, + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "allow", + role: index === 0 ? "administrator" : "viewer", + }, + { + userId: target.user.id, + effect: "allow", + role: "administrator", + }, + ], + }, + }, + }), + ), + ) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("multi-scope-actor-token", target.membership.id, { + status: "removed", + }), + ).rejects.toMatchObject({ + status: 409, + code: "product_administration_orphaned", + }) + expect(await store.read.session(digestSecret("multi-scope-target-token"))).toBeTruthy() + + await prisma.prototypeAccessGrant.update({ + where: { + prototypeId_userId: { + prototypeId: prototypes[1]!.id, + userId: actor.user.id, + }, + }, + data: { role: "administrator" }, + }) + await expect( + service.changeMembership("multi-scope-actor-token", target.membership.id, { + status: "removed", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + status: "removed", + }) + expect(await store.read.session(digestSecret("multi-scope-target-token"))).toBeNull() + }) }) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 1d1ec9b7..741f1c4e 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -1382,6 +1382,32 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it.each([ + { + resource: "malformed workspace", + operation: (service: ReturnType, token: string) => + service.listPrototypes(token, "not-a-workspace-id", {}), + code: "workspace_not_found", + }, + { + resource: "missing prototype", + operation: (service: ReturnType, token: string) => + service.listVersions(token, "00000000-0000-4000-8000-000000000099", {}), + code: "prototype_not_found", + }, + ])("returns a resource 404 for a $resource path ID", async ({ operation, code }) => { + const { service, store } = await database() + await createActor(store, { + subject: `path-${code}`, + token: `path-${code}-token`, + }) + + await expect(operation(service, `path-${code}-token`)).rejects.toMatchObject({ + status: 404, + code, + }) + }) + it("continues workspace pagination after access to the prior cursor is revoked", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { @@ -1878,6 +1904,67 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(new Set([...first.items, ...second.items].map(({ id }) => id)).size).toBe(4) }) + it("rejects a prototype cursor from another workspace", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "cross-workspace-cursor", + token: "cross-workspace-cursor-token", + }) + const workspaces = await Promise.all( + ["First workspace", "Second workspace"].map((name) => + prisma.workspace.create({ + data: { + name, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }), + ), + ) + const firstWorkspacePrototypeIds = [ + "00000000-0000-4000-8000-000000000011", + "00000000-0000-4000-8000-000000000013", + ] + await prisma.prototype.createMany({ + data: [ + ...firstWorkspacePrototypeIds.map((id) => ({ + id, + name: `First workspace prototype ${id.at(-1)}`, + workspaceId: workspaces[0]!.id, + createdByUserId: actor.user.id, + })), + { + id: "00000000-0000-4000-8000-000000000012", + name: "Foreign cursor prototype", + workspaceId: workspaces[1]!.id, + createdByUserId: actor.user.id, + }, + ], + }) + + await expect( + service.listPrototypes("cross-workspace-cursor-token", workspaces[0]!.id, { + cursor: "00000000-0000-4000-8000-000000000012", + limit: 1, + }), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_pagination", + }) + const page = (await service.listPrototypes( + "cross-workspace-cursor-token", + workspaces[0]!.id, + {}, + )) as { items: { id: string }[] } + expect(page.items.map(({ id }) => id)).toEqual(firstWorkspacePrototypeIds) + }) + it("lists immutable versions by sequence and paginates in that order", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { @@ -1954,6 +2041,84 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(second.nextCursor).toBeNull() }) + it("rejects a version cursor from another prototype", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "cross-prototype-cursor", + token: "cross-prototype-cursor-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + const prototypes = await Promise.all( + ["First prototype", "Second prototype"].map((name) => + prisma.prototype.create({ + data: { + name, + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }), + ), + ) + const firstPrototypeVersionIds = [ + "00000000-0000-4000-8000-000000000021", + "00000000-0000-4000-8000-000000000022", + ] + await prisma.prototypeVersion.createMany({ + data: [ + { + id: firstPrototypeVersionIds[0], + prototypeId: prototypes[0]!.id, + createdByUserId: actor.user.id, + sequence: 1, + label: "First version", + }, + { + id: firstPrototypeVersionIds[1], + prototypeId: prototypes[0]!.id, + createdByUserId: actor.user.id, + sequence: 2, + label: "Second version", + }, + ], + }) + const foreignVersion = await prisma.prototypeVersion.create({ + data: { + prototypeId: prototypes[1]!.id, + createdByUserId: actor.user.id, + sequence: 1, + label: "Foreign cursor version", + }, + }) + + await expect( + service.listVersions("cross-prototype-cursor-token", prototypes[0]!.id, { + cursor: foreignVersion.id, + limit: 1, + }), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_pagination", + }) + const page = (await service.listVersions( + "cross-prototype-cursor-token", + prototypes[0]!.id, + {}, + )) as { items: { id: string }[] } + expect(page.items.map(({ id }) => id)).toEqual(firstPrototypeVersionIds) + }) + it("creates sequential immutable versions and rejects additions after archival", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index c24e7ebb..a7cfc2b2 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -53,6 +53,36 @@ function parsePage(query: unknown) { return parsed.data } +async function requirePrototypeCursor( + database: PrismaClient, + workspaceId: string, + cursor?: string, +): Promise { + if (!cursor) return + const prototype = await database.prototype.findUnique({ + where: { id: cursor }, + select: { workspaceId: true }, + }) + if (prototype?.workspaceId !== workspaceId) { + throw new AuthenticationError(422, "invalid_pagination", "Pagination is invalid.") + } +} + +async function requireVersionCursor( + database: PrismaClient, + prototypeId: string, + cursor?: string, +): Promise { + if (!cursor) return + const version = await database.prototypeVersion.findUnique({ + where: { id: cursor }, + select: { prototypeId: true }, + }) + if (version?.prototypeId !== prototypeId) { + throw new AuthenticationError(422, "invalid_pagination", "Pagination is invalid.") + } +} + function parseName(input: unknown, code: string, message: string) { const parsed = nameSchema.safeParse(input) if (!parsed.success) throw new AuthenticationError(422, code, message) @@ -619,6 +649,7 @@ export function createPrototypeAuthorizationService({ const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) const { workspace } = await workspaceDecision(database, actor.userId, workspaceId) + await requirePrototypeCursor(database, workspaceId, cursor) const workspaceGrant = workspace.grants[0] ?? null if ( !(await hasAnyPrototypeAccess({ @@ -806,6 +837,7 @@ export function createPrototypeAuthorizationService({ const actor = await currentSession(store, sessionToken) const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) requireDecision(decision, "viewer") + await requireVersionCursor(database, prototypeId, cursor) const items = await database.prototypeVersion.findMany({ where: { prototypeId }, orderBy: { sequence: "asc" }, From 5000059b05f76cc2559f481ca5970fa30f5d2f65 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 15:58:44 -0700 Subject: [PATCH 19/56] feat(hub): synchronize GitHub authorization Add opt-in GitHub-synchronized admission and prototype baselines while preserving explicit Hub overrides and bounded stale-access behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 6 +- hub/README.md | 4 +- hub/docs/README.md | 3 +- hub/docs/architecture.md | 6 +- hub/docs/database.md | 11 +- hub/docs/github-integration.md | 52 ++ hub/docs/identity.md | 12 +- hub/docs/membership-and-administration.md | 11 +- hub/docs/prototype-authorization.md | 30 +- hub/docs/testing-and-operations.md | 6 +- hub/schema/github-authorization-policy.prisma | 13 + hub/schema/github-repository-source.prisma | 33 + hub/schema/github-synchronized-access.prisma | 28 + hub/schema/github-webhook-delivery.prisma | 21 + .../migration.sql | 110 +++ hub/schema/prototype.prisma | 8 +- hub/schema/security-audit.prisma | 3 + hub/schema/user.prisma | 1 + hub/server/create-app/index.ts | 10 +- .../administration/create-service/index.ts | 2 +- .../complete-github-authorization/index.ts | 36 +- .../create-admission-policy/index.ts | 18 +- .../authentication/create-runtime/index.ts | 18 +- .../create-service/index.db.test.ts | 2 +- .../create-service/membership.test.ts | 2 +- .../authentication/create-service/types.ts | 11 +- .../create-service/index.db.test.ts | 431 +++++++++ .../create-service/index.ts | 815 ++++++++++++++++++ .../create-service/types.ts | 49 ++ .../map-permission/index.test.ts | 25 + .../map-permission/index.ts | 25 + .../create-service/index.db.test.ts | 29 +- .../create-service/index.ts | 157 +++- .../create-service/types.ts | 9 +- .../evaluate-access/index.test.ts | 43 +- .../evaluate-access/index.ts | 17 +- .../evaluate-access/types.ts | 20 +- hub/server/index.ts | 8 + .../check-repository-admission/constants.ts | 18 +- .../security-audits/save-audit/index.ts | 1 + .../repositories/security-audits/types.ts | 2 + .../routes/github-authorization/index.test.ts | 136 +++ .../routes/github-authorization/index.ts | 103 +++ hub/server/routes/index.ts | 33 + hub/server/routes/product/index.ts | 13 + hub/server/routes/types.ts | 2 + 46 files changed, 2303 insertions(+), 90 deletions(-) create mode 100644 hub/docs/github-integration.md create mode 100644 hub/schema/github-authorization-policy.prisma create mode 100644 hub/schema/github-repository-source.prisma create mode 100644 hub/schema/github-synchronized-access.prisma create mode 100644 hub/schema/github-webhook-delivery.prisma create mode 100644 hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql create mode 100644 hub/server/domains/github-authorization/create-service/index.db.test.ts create mode 100644 hub/server/domains/github-authorization/create-service/index.ts create mode 100644 hub/server/domains/github-authorization/create-service/types.ts create mode 100644 hub/server/domains/github-authorization/map-permission/index.test.ts create mode 100644 hub/server/domains/github-authorization/map-permission/index.ts create mode 100644 hub/server/routes/github-authorization/index.test.ts create mode 100644 hub/server/routes/github-authorization/index.ts diff --git a/hub/.env.example b/hub/.env.example index 33368342..e75caf95 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -21,7 +21,8 @@ TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres # HUB_GITHUB_AUTH_CLIENT_ID= # HUB_GITHUB_AUTH_CLIENT_SECRET= -# GitHub proves identity by default. Select legacy-repository only for compatibility. +# GitHub proves identity by default. github-synchronized admits users through explicitly +# linked repository sources. legacy-repository preserves the earlier compatibility behavior. HUB_ADMISSION_POLICY=hub-managed # Generate separate host-held values. Bootstrap can enroll the first administrator once. @@ -41,3 +42,6 @@ HUB_GITHUB_CREDENTIAL_PROVIDER=database # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= # GITHUB_PRIVATE_KEY= + +# Required only for /api/github/webhooks. Keep it separate from user-auth credentials. +# HUB_GITHUB_WEBHOOK_SECRET= diff --git a/hub/README.md b/hub/README.md index 2ea2df92..9d807df3 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,9 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API exposes its health check, GitHub authentication, sessions, and installation membership administration. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. +The API exposes its health check, GitHub authentication, sessions, installation membership administration, prototype authorization, and opt-in GitHub-synchronized admission and prototype baselines. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, invitations, membership roles and lifecycle, administrator recovery, and session revocation are implemented. Prototype authorization, saved prototype data, live updates, background jobs, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, optional GitHub-synchronized authorization with Hub overrides, invitations, membership roles and lifecycle, administrator recovery, session revocation, and prototype metadata are implemented. Artifact ingestion, previews, reviews, general background-job infrastructure, and deployment are not implemented. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/docs/README.md b/hub/docs/README.md index f08b592c..8f9b9166 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -8,8 +8,9 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. - [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. +- [GitHub integration](github-integration.md): linked repository sources, reconciliation, webhooks, staleness, and permission mapping. - [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. -- [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, direct roles, explicit deny, and policy precedence. +- [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, Hub overrides, synchronized baselines, and policy precedence. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index ab1e7235..566ce451 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, and Hub-native prototype authorization. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, Hub-native prototype authorization, and opt-in GitHub-synchronized admission and prototype baselines. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page 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 system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, and one policy evaluator. Deployment and background jobs have not been built. This app cannot yet find repositories, ingest artifacts, host previews, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, synchronized GitHub baselines, and one policy evaluator. The [GitHub integration](github-integration.md) stores stable repository source records and reconciles current permissions with installation credentials. Deployment and general background-job infrastructure have not been built. This app does not ingest artifacts, host previews, browse source, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Product routes create and read workspaces, prototypes, and immutable versions; archive prototypes; explain effective access; and administer direct access rules. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, and prototype authorization runtime and starts the API at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Product routes create and read workspaces, prototypes, and immutable versions; archive prototypes; choose Hub-managed or GitHub-synchronized prototype authorization; explain effective access; and administer direct access rules. Administration routes link repository sources, opt them into admission, inspect synchronization state, and trigger reconciliation. A verified GitHub App webhook route marks affected sources dirty or removed. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, prototype authorization, and GitHub synchronization runtime, starts the API at a local address, and schedules bounded periodic reconciliation. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. diff --git a/hub/docs/database.md b/hub/docs/database.md index f75cece5..6781af1f 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -46,7 +46,16 @@ model Folder { `uuid()` generates UUIDs through Prisma. `@updatedAt` updates timestamps on Prisma writes, not through a database trigger. Raw SQL writers must supply UUIDs and maintain `updated_at` themselves. Use timezone-aware timestamps for stored instants. -Workspace and prototype access use separate grant tables so each user has at most one rule per scope. The database requires an allowed rule to have a role and an explicit deny to have no role. Prototype versions have a unique sequence within their owning prototype and use restrictive deletion so history cannot be detached or silently removed. +Workspace and prototype access use separate grant tables so each user has at most one manual rule per scope. The database requires an allowed rule to have a role and an explicit deny to have no role. Prototype versions have a unique sequence within their owning prototype and use restrictive deletion so history cannot be detached or silently removed. + +GitHub synchronization uses separate records: + +- `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, and synchronization timestamps do not define identity. +- `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. +- `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. +- `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. + +`Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. ## Shared client diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md new file mode 100644 index 00000000..f3455675 --- /dev/null +++ b/hub/docs/github-integration.md @@ -0,0 +1,52 @@ +# GitHub integration + +GitHub-synchronized authorization uses repository permissions as an opt-in baseline below Hub overrides. It is not repository-gated authorization: Hub links the source, chooses where synchronization applies, maps the permission, enforces staleness, and makes the final admission and prototype decisions. + +## Source identity and credentials + +A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, active or removed state, admission switch, synchronization status, and timestamps. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. + +All permission checks use an installation token created from the configured GitHub source-integration credentials. GitHub user authorization proves identity only. Hub does not retain or reuse the user's transient token for synchronization. + +## Opt-in policies + +`HUB_ADMISSION_POLICY=hub-managed` is the default. Select `github-synchronized` to permit linked sources to participate, then enable admission on each intended repository through the administration API. No linked or opted-in source means no synchronized admission. + +Each prototype separately selects `hub-managed` or `github-synchronized`. The synchronized mode also requires one active repository source. Unlinking a source removes its synchronized baselines and leaves the prototype without a usable baseline until an administrator selects another source or returns it to Hub-managed mode. + +`legacy-repository` is a compatibility policy for current Replica behavior. It scans installation repositories during sign-in and is intentionally not the default or the normal synchronized source model. + +## Permission mapping + +| Raw GitHub permission | Admission | Prototype baseline | +| --- | --- | --- | +| `admin`, `maintain`, `write` | Member | `editor` | +| `triage` | Member | `commenter` | +| `read` | Member | `viewer` | +| `none` or missing | Denied | None | + +GitHub does not map to Hub installation administrator or prototype administrator. Explicit Hub deny wins first, followed by explicit prototype and workspace roles, then the synchronized baseline. + +## Reconciliation and webhooks + +`POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. + +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository or installation removal marks synchronized records removed. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. + +## Staleness and outages + +A successful confirmation records `lastConfirmedAt`. Failed provider calls record a bounded failure status and leave that time unchanged. Hub permits a last-known-good baseline for one day, then fails closed. Reconciliation also removes synchronized memberships and sessions when no fresh admission-eligible source remains. + +Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. + +## Administration API + +- `GET /api/administration/github/repositories` +- `POST /api/administration/github/repositories` +- `PATCH /api/administration/github/repositories/:repositorySourceId` +- `DELETE /api/administration/github/repositories/:repositorySourceId` +- `POST /api/administration/github/repositories/:repositorySourceId/reconcile` +- `PUT /api/prototypes/:prototypeId/authorization` +- `GET /api/prototypes/:prototypeId/access/explanation` + +Administration mutations require an active installation administrator, fresh verification, the configured origin, and matching CSRF cookie and header. Product policy changes require a fresh effective prototype administrator. Request bodies are omitted from logs, responses are private and non-cacheable, and mutations write structured audits in the same transaction. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index d762df4e..8aa6018c 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,6 +1,6 @@ # Hub identity -Hub uses GitHub user authorization to prove an external identity. Hub then applies its own installation admission and membership rules before creating a provider-neutral browser session. Repository permissions do not grant installation membership. +Hub uses GitHub user authorization to prove an external identity. Hub then applies its configured installation admission and membership rules before creating a provider-neutral browser session. Repository permissions grant installation membership only when an operator explicitly selects GitHub-synchronized admission and opts in at least one linked repository. ## GitHub user authentication @@ -12,7 +12,11 @@ The callback consumes an attempt once, checks the same-browser binding and curre Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. -`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to create a member assignment after repository admission. The GitHub authentication adapter never reads installation credentials or performs repository authorization. +`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. + +`github-synchronized` is opt-in. It checks only repository source records whose admission switch is enabled, uses the separately configured GitHub App installation credentials, and records the confirmed raw permission and mapped baseline separately from the membership. A qualifying baseline creates or refreshes only a `synchronized` member assignment. Manual, invitation, bootstrap, recovery, suspended, and removed assignments remain Hub overrides and are never rewritten by synchronization. Permission loss removes a synchronized membership and its sessions only after reconciliation or the bounded last-known-good window expires. + +`legacy-repository` preserves the previous compatibility behavior. It scans repositories available to the installation and creates a synchronized member assignment after finding any repository permission, without requiring linked source records. It is not the normal synchronized authorization mode. The GitHub authentication adapter itself never reads installation credentials or performs repository authorization. Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. @@ -46,12 +50,12 @@ GitHub source access is an optional integration boundary, not the global identit | `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 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. A deployment may use the same GitHub App for user authorization and source integration, but it supplies and rotates the two configurations independently. +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. A deployment may use the same GitHub App for user authorization and source integration, but it supplies and rotates the two configurations independently. Set `HUB_GITHUB_WEBHOOK_SECRET` separately when enabling the webhook route. 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. ## Current scope -This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary uses these sessions without treating membership or repository access as product authority. Identity does not add GitHub membership synchronization, artifact ingestion, production OIDC, account linking, or SCIM. +This layer adds GitHub sign-in, Hub-managed and opt-in GitHub-synchronized installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary may consume a synchronized repository baseline without treating installation membership as product authority. Identity does not add artifact ingestion, production OIDC, account linking, or SCIM. Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index 60b9e9b9..6fd9c534 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -1,6 +1,6 @@ # Installation membership and administration -Hub owns installation admission after an authentication provider proves an identity. Repository access and installation membership are separate decisions. +Hub owns installation admission after an authentication provider proves an identity. Repository access affects installation membership only through the explicit GitHub-synchronized policy; Hub remains the final evaluator. ## Membership contract @@ -11,10 +11,12 @@ Each Hub user has at most one installation membership. | Active member | May create and restore a Hub session. Product access remains a separate future decision. | | Active administrator | Has member access and may use installation administration after fresh identity verification. Existing product access remains separate. | | Suspended | Cannot create or use a Hub session. An administrator may restore the membership. | -| Removed | Cannot create or use a Hub session. The retained record preserves audit linkage, and return requires a new invitation. | +| Removed | Cannot create or use a Hub session. The retained record preserves audit linkage. Manual removals require a new invitation; synchronized removals can return only after a new qualifying confirmation. | Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. +Synchronization creates and refreshes only memberships whose source is `synchronized`. It never reactivates or rewrites a manual, invitation, bootstrap, recovery, or suspended membership. When no fresh admission-eligible baseline remains, reconciliation marks the synchronized membership removed and deletes its sessions in the same transaction. + Suspension and removal also check Hub-managed product administration in the same transaction. The change fails with `product_administration_orphaned` until another active user can administer every affected workspace and prototype. A role change from installation administrator to member does not remove explicit workspace or prototype roles. ## Invitations @@ -48,6 +50,9 @@ The minimal administration surface uses: - `POST /api/administration/invitations` - `DELETE /api/administration/invitations/:id` - `PATCH /api/administration/memberships/:id` +- `GET` and `POST /api/administration/github/repositories` +- `PATCH` and `DELETE /api/administration/github/repositories/:repositorySourceId` +- `POST /api/administration/github/repositories/:repositorySourceId/reconcile` - `POST /api/auth/bootstrap` - `POST /api/auth/recovery` @@ -59,4 +64,4 @@ Membership operations write a structured security record with a bounded action, ## Non-goals -This boundary does not grant prototype, repository, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines Hub-managed product access. Membership does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. +This boundary does not grant direct prototype, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines product access and can consume a repository baseline below Hub overrides. Membership synchronization does not browse source, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 83c7bf01..ee0131f7 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -1,6 +1,6 @@ # Prototype authorization -Hub owns prototype access after authentication and installation admission. A user does not need repository access to use a Hub-managed prototype, and GitHub repository permissions do not grant Hub access. +Hub owns prototype access after authentication and installation admission. A user does not need repository access to use a Hub-managed prototype. A prototype can explicitly select a linked GitHub repository as a synchronized baseline, but Hub remains the final evaluator. ## Product records @@ -25,19 +25,32 @@ Artifact payloads are not part of this layer. A version identifies immutable pro Installation membership and installation administrator status do not grant access to existing workspaces or prototypes. The workspace-creation rule is the only installation-administrator product rule in this layer. After creation, the user's explicit workspace grant supplies authority. -## Direct access and precedence +## Hub overrides, synchronized baselines, and precedence Hub stores one direct workspace rule and one direct prototype rule per user and scope. A rule is either an explicit deny or an allowed role. Removing a rule restores evaluation from the remaining sources. +Each prototype starts in `hub-managed` mode. An administrator may explicitly select `github-synchronized` mode and one active linked repository source. There is no implicit fallback from missing Hub access to GitHub. + The evaluator applies this order: 1. An explicit Hub deny at either the workspace or prototype scope denies access. 2. An explicit prototype role applies, including a lower role that intentionally restricts a stronger workspace role. 3. An explicit workspace role applies. -4. A future optional synchronized baseline may apply. +4. A fresh GitHub-synchronized baseline may apply. 5. Otherwise Hub denies access. -Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. Hub consults an optional baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing accepts that baseline policy source and checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. GitHub baseline decisions also report the repository source, immutable GitHub repository ID, raw permission, last confirmation time, and synchronization status. Routes use this evaluator instead of reimplementing precedence. Hub consults the synchronized baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. + +The fixed mapping is: + +| GitHub permission | Hub prototype baseline | +| --- | --- | +| `admin`, `maintain`, `write` | `editor` | +| `triage` | `commenter` | +| `read` | `viewer` | +| `none` or missing collaborator | No access | + +GitHub never supplies Hub `administrator` authority. Manual workspace and prototype grants and denies live in separate tables and synchronization does not update or delete them. Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. @@ -55,6 +68,7 @@ The product API uses: - `GET` and `POST /api/workspaces/:workspaceId/prototypes` - `GET /api/prototypes/:prototypeId` - `POST /api/prototypes/:prototypeId/archive` +- `PUT /api/prototypes/:prototypeId/authorization` - `GET /api/prototypes/:prototypeId/access/explanation` - `PUT` and `DELETE /api/prototypes/:prototypeId/access/:userId` - `GET` and `POST /api/prototypes/:prototypeId/versions` @@ -70,6 +84,10 @@ Workspace creation, prototype creation and archival, version creation, role gran Records identify the actor, session, authentication-provider revision, affected product records, target user when applicable, outcome, and a bounded reason. They do not include cookies, provider tokens, host secrets, request bodies, prototype artifacts, or mutable identity labels. -## Deferred policy sources +## Synchronization lifecycle + +The [GitHub integration](github-integration.md) confirms permissions with installation credentials. Webhooks only mark relevant sources dirty or removed; periodic or manual reconciliation repairs missed events and confirms current permissions. A failed provider call does not refresh confirmation. Baselines remain usable only inside the configured one-day last-known-good window and fail closed afterward. + +Repository rename changes mutable owner, name, and default-branch fields without changing the immutable source identity. Repository removal, installation removal, and permission loss update synchronized baselines only. Direct Hub overrides remain intact. -GitHub repository synchronization, webhooks, reconciliation, and repository-to-role mapping are not implemented. They may later produce an optional baseline role without changing Hub's direct-rule precedence. Source-provider mutations, public links, artifact ingestion, promotion, retention, and rollback remain separate product boundaries. +Source mutation, public links, artifact ingestion, promotion, retention, and rollback remain separate product boundaries. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 7c39beb6..58c80f9b 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -55,7 +55,11 @@ Start the PostgreSQL service as described in [Development](development.md) and s 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. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. -Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. + +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, repository rename and removal, installation removal, permission loss, provider outage, dirty webhooks, replayed deliveries, missed-event repair, manual override preservation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. + +The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. 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/github-authorization-policy.prisma b/hub/schema/github-authorization-policy.prisma new file mode 100644 index 00000000..3683cedb --- /dev/null +++ b/hub/schema/github-authorization-policy.prisma @@ -0,0 +1,13 @@ +model GitHubAuthorizationPolicy { + // ID columns + id String @id @db.Uuid + + // Attribute columns + lastKnownGoodSeconds Int @default(86400) @map("last_known_good_seconds") + + // 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_authorization_policies") +} diff --git a/hub/schema/github-repository-source.prisma b/hub/schema/github-repository-source.prisma new file mode 100644 index 00000000..cda1b3ef --- /dev/null +++ b/hub/schema/github-repository-source.prisma @@ -0,0 +1,33 @@ +model GitHubRepositorySource { + // ID columns + id String @id @default(uuid()) @db.Uuid + githubRepositoryId String @unique @map("github_repository_id") + + // Relations + prototypes Prototype[] + synchronizedAccess GitHubSynchronizedAccess[] + webhookDeliveries GitHubWebhookDelivery[] + auditTargets SecurityAudit[] + + // Attribute columns + appId String @map("app_id") + installationId String @map("installation_id") + owner String + name String + defaultBranch String @map("default_branch") + status String @default("active") + admissionEnabled Boolean @default(false) @map("admission_enabled") + synchronizationStatus String @default("dirty") @map("synchronization_status") + synchronizationError String? @map("synchronization_error") + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + dirtyAt DateTime? @map("dirty_at") @db.Timestamptz(3) + lastAttemptedAt DateTime? @map("last_attempted_at") @db.Timestamptz(3) + lastConfirmedAt DateTime? @map("last_confirmed_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([installationId, status]) + @@map("github_repository_sources") +} diff --git a/hub/schema/github-synchronized-access.prisma b/hub/schema/github-synchronized-access.prisma new file mode 100644 index 00000000..44fe6e8c --- /dev/null +++ b/hub/schema/github-synchronized-access.prisma @@ -0,0 +1,28 @@ +model GitHubSynchronizedAccess { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String @map("repository_source_id") @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource @relation(fields: [repositorySourceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + // Attribute columns + rawPermission String @map("raw_permission") + mappedPrototypeRole PrototypeRole? @map("mapped_prototype_role") + admissionEligible Boolean @map("admission_eligible") + status String @default("active") + source String @default("github_app_installation") + synchronizationStatus String @default("current") @map("synchronization_status") + lastConfirmedAt DateTime @map("last_confirmed_at") @db.Timestamptz(3) + removedAt DateTime? @map("removed_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) + + @@unique([repositorySourceId, userId]) + @@index([userId, status]) + @@map("github_synchronized_access") +} diff --git a/hub/schema/github-webhook-delivery.prisma b/hub/schema/github-webhook-delivery.prisma new file mode 100644 index 00000000..39f1316c --- /dev/null +++ b/hub/schema/github-webhook-delivery.prisma @@ -0,0 +1,21 @@ +model GitHubWebhookDelivery { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String? @map("repository_source_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource? @relation(fields: [repositorySourceId], references: [id], onDelete: SetNull) + + // Attribute columns + deliveryId String @unique @map("delivery_id") + event String + action String? + installationId String @map("installation_id") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([installationId, createdAt]) + @@map("github_webhook_deliveries") +} diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql new file mode 100644 index 00000000..1d328d76 --- /dev/null +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -0,0 +1,110 @@ +-- CreateTable +CREATE TABLE "github_authorization_policies" ( + "id" UUID NOT NULL, + "last_known_good_seconds" INTEGER NOT NULL DEFAULT 86400, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_authorization_policies_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_authorization_policies_last_known_good_seconds" CHECK ( + "last_known_good_seconds" BETWEEN 0 AND 604800 + ) +); + +INSERT INTO "github_authorization_policies" ("id") +VALUES ('00000000-0000-0000-0000-000000000001'); + +-- CreateTable +CREATE TABLE "github_repository_sources" ( + "id" UUID NOT NULL, + "github_repository_id" TEXT NOT NULL, + "app_id" TEXT NOT NULL, + "installation_id" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "name" TEXT NOT NULL, + "default_branch" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "admission_enabled" BOOLEAN NOT NULL DEFAULT false, + "synchronization_status" TEXT NOT NULL DEFAULT 'dirty', + "synchronization_error" TEXT, + "removed_at" TIMESTAMPTZ(3), + "dirty_at" TIMESTAMPTZ(3), + "last_attempted_at" TIMESTAMPTZ(3), + "last_confirmed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_repository_sources_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_repository_sources_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_repository_sources_synchronization_status" CHECK ( + "synchronization_status" IN ('dirty', 'current', 'failed', 'removed') + ) +); + +-- CreateTable +CREATE TABLE "github_synchronized_access" ( + "id" UUID NOT NULL, + "repository_source_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "raw_permission" TEXT NOT NULL, + "mapped_prototype_role" "PrototypeRole", + "admission_eligible" BOOLEAN NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "source" TEXT NOT NULL DEFAULT 'github_app_installation', + "synchronization_status" TEXT NOT NULL DEFAULT 'current', + "last_confirmed_at" TIMESTAMPTZ(3) NOT NULL, + "removed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_synchronized_access_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_synchronized_access_raw_permission" CHECK ( + "raw_permission" IN ('admin', 'maintain', 'write', 'triage', 'read', 'none') + ), + CONSTRAINT "github_synchronized_access_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_synchronized_access_synchronization_status" CHECK ( + "synchronization_status" IN ('current', 'dirty', 'failed', 'removed') + ) +); + +-- CreateTable +CREATE TABLE "github_webhook_deliveries" ( + "id" UUID NOT NULL, + "repository_source_id" UUID, + "delivery_id" TEXT NOT NULL, + "event" TEXT NOT NULL, + "action" TEXT, + "installation_id" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_webhook_deliveries_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "prototypes" +ADD COLUMN "authorization_mode" TEXT NOT NULL DEFAULT 'hub-managed', +ADD COLUMN "source_repository_id" UUID, +ADD CONSTRAINT "prototypes_authorization_mode" CHECK ( + "authorization_mode" IN ('hub-managed', 'github-synchronized') +); + +ALTER TABLE "security_audits" +ADD COLUMN "github_repository_source_id" UUID; + +-- CreateIndex +CREATE UNIQUE INDEX "github_repository_sources_github_repository_id_key" ON "github_repository_sources"("github_repository_id"); +CREATE INDEX "github_repository_sources_installation_id_status_idx" ON "github_repository_sources"("installation_id", "status"); +CREATE UNIQUE INDEX "github_synchronized_access_repository_source_id_user_id_key" ON "github_synchronized_access"("repository_source_id", "user_id"); +CREATE INDEX "github_synchronized_access_user_id_status_idx" ON "github_synchronized_access"("user_id", "status"); +CREATE UNIQUE INDEX "github_webhook_deliveries_delivery_id_key" ON "github_webhook_deliveries"("delivery_id"); +CREATE INDEX "github_webhook_deliveries_installation_id_created_at_idx" ON "github_webhook_deliveries"("installation_id", "created_at"); +CREATE INDEX "prototypes_source_repository_id_idx" ON "prototypes"("source_repository_id"); +CREATE INDEX "security_audits_github_repository_source_id_idx" ON "security_audits"("github_repository_source_id"); + +-- AddForeignKey +ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "github_webhook_deliveries" ADD CONSTRAINT "github_webhook_deliveries_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_source_repository_id_fkey" FOREIGN KEY ("source_repository_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_github_repository_source_id_fkey" FOREIGN KEY ("github_repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/schema/prototype.prisma b/hub/schema/prototype.prisma index fd2bdfd2..5064e50a 100644 --- a/hub/schema/prototype.prisma +++ b/hub/schema/prototype.prisma @@ -9,10 +9,13 @@ model Prototype { createdBy User @relation("PrototypeCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) versions PrototypeVersion[] grants PrototypeAccessGrant[] + sourceRepository GitHubRepositorySource? @relation(fields: [sourceRepositoryId], references: [id], onDelete: SetNull) // Attribute columns - name String - archivedAt DateTime? @map("archived_at") @db.Timestamptz(3) + name String + authorizationMode String @default("hub-managed") @map("authorization_mode") + sourceRepositoryId String? @map("source_repository_id") @db.Uuid + archivedAt DateTime? @map("archived_at") @db.Timestamptz(3) // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) @@ -20,5 +23,6 @@ model Prototype { @@index([workspaceId]) @@index([createdByUserId]) + @@index([sourceRepositoryId]) @@map("prototypes") } diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index 312de7fc..ed0b61c4 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -10,10 +10,12 @@ model SecurityAudit { prototypeId String? @map("prototype_id") @db.Uuid prototypeVersionId String? @map("prototype_version_id") @db.Uuid targetUserId String? @map("target_user_id") @db.Uuid + githubRepositorySourceId String? @map("github_repository_source_id") @db.Uuid // Relations targetMembership InstallationMembership? @relation("TargetMembership", fields: [targetMembershipId], references: [id], onDelete: SetNull) invitation MembershipInvitation? @relation(fields: [invitationId], references: [id], onDelete: SetNull) + githubRepositorySource GitHubRepositorySource? @relation(fields: [githubRepositorySourceId], references: [id], onDelete: SetNull) // Attribute columns event String @@ -31,5 +33,6 @@ model SecurityAudit { @@index([prototypeId]) @@index([prototypeVersionId]) @@index([targetUserId]) + @@index([githubRepositorySourceId]) @@map("security_audits") } diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma index 3e9abf4f..0280e775 100644 --- a/hub/schema/user.prisma +++ b/hub/schema/user.prisma @@ -12,6 +12,7 @@ model User { createdVersions PrototypeVersion[] @relation("PrototypeVersionCreator") workspaceGrants WorkspaceAccessGrant[] prototypeGrants PrototypeAccessGrant[] + githubSynchronizedAccess GitHubSynchronizedAccess[] // Attribute columns displayName String? @map("display_name") diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index dcdb588d..22872cd7 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -18,9 +18,17 @@ export function createApp({ app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) app.use( - ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], + [ + "/api/auth", + "/api/session", + "/api/administration", + "/api/github/webhooks", + "/api/workspaces", + "/api/prototypes", + ], omitRequestLogPayload, ) + app.use("/api/github/webhooks", express.raw({ type: "application/json", limit: "1mb" })) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index fdd8db24..1edcfad9 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -34,7 +34,7 @@ function requireFreshAdministrator( github: null, configuration: null, providerRevisionId: null, - admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(null) }, now: dependencies.now, }, sessionToken, diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index a6e6a4e5..fc72f566 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -47,11 +47,12 @@ export async function completeGitHubAuthorization( } const purpose: AuthorizationPurpose = attempt.purpose let profile: GitHubUserProfile - let policyAdmitted = false + let admissionBaseline: Awaited> = null try { profile = await github.identify(code, attempt.pkceVerifier) if (attempt.purpose === "sign-in") { - policyAdmitted = await admissionPolicy.admit(profile) + const admission = await admissionPolicy.admit(profile) + admissionBaseline = admission === true ? "legacy" : admission || null } } catch (error) { await store.lock((records) => @@ -70,7 +71,7 @@ export async function completeGitHubAuthorization( "The authentication provider failed.", ) } - const result = await store.lock(async (records) => { + const result = await store.lock(async (records, transaction) => { const [currentProvider, stateRecord] = await Promise.all([ records.currentAuthenticationProviderRevision("github"), records.identityStoreState(), @@ -109,18 +110,38 @@ export async function completeGitHubAuthorization( } let identity = await records.externalIdentity("github", configuration.issuer, profile.subject) let membership = identity ? await records.membership(identity.userId) : null + const synchronizableMembership = + admissionBaseline !== null && (membership === null || membership.source === "synchronized") const invitation = purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" && - !policyAdmitted + !admissionBaseline ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) : null + if ( + purpose === "sign-in" && + membership?.status === "active" && + membership.source === "synchronized" && + admissionPolicy.name === "github-synchronized" && + !admissionBaseline + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) + return { denied: true as const } + } if ( purpose === "sign-in" && membership?.status !== "active" && !invitation && - !(policyAdmitted && !membership) + !synchronizableMembership ) { await records.audit({ event: "authentication.admission_denied", @@ -254,7 +275,7 @@ export async function completeGitHubAuthorization( targetMembershipId: membership.id, invitationId: invitation.id, }) - } else if (policyAdmitted && !membership) { + } else if (synchronizableMembership) { await records.saveMembership({ userId: identity.userId, status: "active", @@ -265,6 +286,9 @@ export async function completeGitHubAuthorization( }) } } + if (admissionBaseline && admissionBaseline !== "legacy") { + await admissionPolicy.saveBaseline?.(transaction, identity.userId, admissionBaseline) + } const sessionToken = randomBytes(32).toString("base64url") const session = await records.createSession({ userId: identity.userId, diff --git a/hub/server/domains/authentication/create-admission-policy/index.ts b/hub/server/domains/authentication/create-admission-policy/index.ts index 309e3acb..f1aae2d8 100644 --- a/hub/server/domains/authentication/create-admission-policy/index.ts +++ b/hub/server/domains/authentication/create-admission-policy/index.ts @@ -1,19 +1,31 @@ import type { AdmissionPolicy } from "#server/domains/authentication/create-service/types" import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" import { checkRepositoryAdmission } from "#server/integrations/github/check-repository-admission" import { AuthenticationError } from "#server/utils/authentication-error" export function createAdmissionPolicy( environment: NodeJS.ProcessEnv, store: IdentityStore, + githubAuthorization: GitHubAuthorizationService, ): AdmissionPolicy { const selected = environment.HUB_ADMISSION_POLICY ?? "hub-managed" if (selected === "hub-managed") { - return { name: "hub-managed", admit: () => Promise.resolve(false) } + return { name: "hub-managed", admit: () => Promise.resolve(null) } + } + if (selected === "github-synchronized") { + return { + name: "github-synchronized", + admit: (profile) => githubAuthorization.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + githubAuthorization.saveAdmissionBaseline(database, userId, baseline), + } } if (selected !== "legacy-repository") { - throw new Error("HUB_ADMISSION_POLICY must be hub-managed or legacy-repository.") + throw new Error( + "HUB_ADMISSION_POLICY must be hub-managed, github-synchronized, or legacy-repository.", + ) } const provider = createGitHubIntegrationCredentialProvider(environment) return { @@ -28,7 +40,7 @@ export function createAdmissionPolicy( "GitHub source integration is not configured.", ) } - return await checkRepositoryAdmission(credentials, profile) + return (await checkRepositoryAdmission(credentials, profile)) ? "legacy" : null } catch (error) { if (error instanceof AuthenticationError) throw error throw new AuthenticationError( diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index f4d0a627..492d46e1 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -5,6 +5,7 @@ import type { AuthenticationSettings } from "#server/domains/authentication/crea import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" @@ -66,10 +67,15 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv const origin = readOrigin(environment) const configuration = readGitHubAuthentication(environment, origin) const store = createIdentityStore(prisma) + const githubAuthorization = createGitHubAuthorizationService({ + database: prisma, + environment, + store, + }) const providerRevision = configuration ? await ensureGitHubProviderRevision(store, configuration) : null - const admissionPolicy = createAdmissionPolicy(environment, store) + const admissionPolicy = createAdmissionPolicy(environment, store, githubAuthorization) const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") if (bootstrapSecret && recoverySecret && bootstrapSecret === recoverySecret) { @@ -84,7 +90,15 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv } return { administration: createAdministrationService({ store }), - prototypeAuthorization: createPrototypeAuthorizationService({ database: prisma, store }), + githubAuthorization, + prototypeAuthorization: createPrototypeAuthorizationService({ + baseline: { + baseline: (userId, prototypeId) => + githubAuthorization.prototypeBaseline(prisma, userId, prototypeId), + }, + database: prisma, + store, + }), service: createAuthenticationService({ store, github: configuration ? createGitHubUserAuthClient(configuration) : null, diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 510dfce9..2a2c7eca 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -101,7 +101,7 @@ describe("createAuthenticationService() database behavior", () => { github: null, configuration: null, providerRevisionId: null, - admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(true) }, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve("legacy") }, }) const results = await Promise.allSettled([ diff --git a/hub/server/domains/authentication/create-service/membership.test.ts b/hub/server/domains/authentication/create-service/membership.test.ts index 720d7e4d..ec938477 100644 --- a/hub/server/domains/authentication/create-service/membership.test.ts +++ b/hub/server/domains/authentication/create-service/membership.test.ts @@ -65,7 +65,7 @@ describe("createAuthenticationService() membership enforcement", () => { providerRevisionId: null, admissionPolicy: { name: "hub-managed", - admit: () => Promise.resolve(false), + admit: () => Promise.resolve(null), }, }) diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts index cfd6b3c6..7c905fe5 100644 --- a/hub/server/domains/authentication/create-service/types.ts +++ b/hub/server/domains/authentication/create-service/types.ts @@ -5,12 +5,19 @@ import type { GitHubUserProfile, } from "#server/integrations/github/create-user-auth-client/types" import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" +import type { GitHubAdmissionBaseline } from "#server/domains/github-authorization/create-service/types" +import type { Prisma } from "../../../generated/prisma/client.ts" export type AuthorizationPurpose = "sign-in" | "verify" | "bootstrap" | "recovery" export interface AdmissionPolicy { - name: "hub-managed" | "legacy-repository" - admit(profile: GitHubUserProfile): Promise + name: "hub-managed" | "github-synchronized" | "legacy-repository" + admit(profile: GitHubUserProfile): Promise + saveBaseline?( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + ): Promise } export interface AuthenticationSettings { diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts new file mode 100644 index 00000000..29fdede5 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -0,0 +1,431 @@ +import { createHmac, generateKeyPairSync } from "node:crypto" +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +function environment() { + const privateKey = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey + return { + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", + HUB_GITHUB_WEBHOOK_SECRET: "synthetic-webhook-secret", + GITHUB_APP_ID: "12345", + GITHUB_INSTALLATION_ID: "7", + GITHUB_CLIENT_ID: "test-client", + GITHUB_CLIENT_SECRET: "synthetic-client-secret", + GITHUB_PRIVATE_KEY: privateKey, + } +} + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + const store = createIdentityStore(prisma) + return { + prisma, + store, + service: createGitHubAuthorizationService({ + database: prisma, + environment: environment(), + store, + }), + } +} + +async function createUser( + store: IdentityStore, + { + subject, + membershipSource = "manual", + token, + }: { + subject: string + membershipSource?: "manual" | "synchronized" + token?: string + }, +) { + return store.lock(async (records) => { + const user = await records.createUser({ displayName: `User ${subject}`, avatarUrl: null }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `user-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source: membershipSource, + }) + const session = token + ? await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }) + : null + return { identity, membership, session, user } + }) +} + +function webhook(body: object, event: string, deliveryId: string) { + const encoded = Buffer.from(JSON.stringify(body)) + return { + body: encoded, + deliveryId, + event, + signature: `sha256=${createHmac("sha256", "synthetic-webhook-secret") + .update(encoded) + .digest("hex")}`, + } +} + +describe("createGitHubAuthorizationService() database behavior", () => { + it("uses only a fresh baseline for the linked repository and keeps repositories isolated", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const [source, otherSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "other", + name: "repository", + defaultBranch: "main", + }, + }), + ]) + const request = vi.fn() + vi.stubGlobal("fetch", request) + + await expect( + service.evaluateAdmission({ + subject: "42", + username: "user-42", + displayName: "User 42", + avatarUrl: null, + }), + ).resolves.toBeNull() + expect(request).not.toHaveBeenCalled() + await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { admissionEnabled: true }, + }) + + const workspace = await prisma.workspace.create({ + data: { name: "Workspace", createdByUserId: actor.user.id }, + }) + const [prototype, otherPrototype] = await Promise.all([ + prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }, + }), + prisma.prototype.create({ + data: { + name: "Other", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: otherSource.id, + }, + }), + ]) + const confirmedAt = new Date("2026-10-27T20:00:00.000Z") + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt, + }) + await prisma.gitHubAuthorizationPolicy.create({ + data: { id: "00000000-0000-0000-0000-000000000001", lastKnownGoodSeconds: 60 }, + }) + + await expect( + service.prototypeBaseline(prisma, actor.user.id, prototype.id, confirmedAt), + ).resolves.toMatchObject({ + role: "editor", + repositorySourceId: source.id, + githubRepositoryId: "100", + rawPermission: "write", + }) + await expect( + service.prototypeBaseline(prisma, actor.user.id, otherPrototype.id, confirmedAt), + ).resolves.toBeNull() + await expect( + service.prototypeBaseline( + prisma, + actor.user.id, + prototype.id, + new Date(confirmedAt.getTime() + 61_000), + ), + ).resolves.toBeNull() + }) + + it("removes synchronized state for a repository webhook while preserving manual rules", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const input = webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-1", + ) + + await expect(service.processWebhook(input)).resolves.toBe("accepted") + await expect(service.processWebhook(input)).resolves.toBe("duplicate") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "viewer" }) + expect(await prisma.gitHubWebhookDelivery.count()).toBe(1) + }) + + it("reconciles by immutable repository ID with an installation token and revokes lost synchronized access", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "session-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "master", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { userId: actor.user.id, effect: "allow", role: "commenter" }, + }, + }, + }) + const responses = [ + { token: "installation-token" }, + { + id: 100, + full_name: "new-owner/new-name", + name: "new-name", + owner: { login: "new-owner" }, + default_branch: "trunk", + }, + { user: { id: 42 }, permission: "none" }, + ] + const request = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( + () => Promise.resolve(new Response(JSON.stringify(responses.shift()), { status: 200 })), + ) + vi.stubGlobal("fetch", request) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect(request.mock.calls.map(([url]) => url)).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/new-owner/new-name/collaborators/user-42/permission", + ]) + expect(new Headers(request.mock.calls[1]?.[1]?.headers).get("authorization")).toBe( + "Bearer installation-token", + ) + const reconciled = await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + expect(reconciled).toMatchObject({ + owner: "new-owner", + name: "new-name", + defaultBranch: "trunk", + synchronizationStatus: "current", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ status: "removed", source: "synchronized" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "commenter" }) + + request.mockReset() + request.mockResolvedValue(new Response(null, { status: 503 })) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + lastConfirmedAt: reconciled?.lastConfirmedAt, + }) + }) + + it("rolls back webhook mutations when the required revocation audit fails", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "session-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_github_revocation_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = 'membership.synchronized.remove' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_github_revocation_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_github_revocation_audit(); + `) + onTestFinished(async () => { + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS reject_github_revocation_audit ON security_audits", + ) + await prisma.$executeRawUnsafe("DROP FUNCTION IF EXISTS reject_github_revocation_audit()") + }) + + await expect( + service.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-audit-failure", + ), + ), + ).rejects.toThrow("audit unavailable") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "active", admissionEnabled: true }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ status: "active" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).not.toBeNull() + expect(await prisma.gitHubWebhookDelivery.count()).toBe(0) + }) +}) diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts new file mode 100644 index 00000000..f125a2ac --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -0,0 +1,815 @@ +import { createHmac, timingSafeEqual } from "node:crypto" +import { z } from "zod" + +import { createIdentityRecords } from "#server/domains/identity/create-records" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" +import { + mapGitHubPermission, + type GitHubRepositoryPermission, +} from "#server/domains/github-authorization/map-permission" +import type { + GitHubAdmissionBaseline, + GitHubAuthorizationService, + GitHubPrototypeBaseline, +} from "#server/domains/github-authorization/create-service/types" +import { + collaboratorPermissionSchema, + repositorySchema, +} from "#server/integrations/github/check-repository-admission/constants" +import { createInstallationToken } from "#server/integrations/github/create-installation-token" +import { GitHubApiError } from "#server/integrations/github/github-api-error" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { AuthenticationError } from "#server/utils/authentication-error" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +const policyId = "00000000-0000-0000-0000-000000000001" +const repositoryIdSchema = z.string().regex(/^[1-9]\d{0,19}$/) +const identifierSchema = z.uuid() +const linkSchema = z.object({ + githubRepositoryId: repositoryIdSchema, + admissionEnabled: z.boolean().default(false), +}) +const updateSchema = z.object({ admissionEnabled: z.boolean() }) +const webhookSchema = z.object({ + action: z.string().optional(), + installation: z.object({ id: z.number().int().positive() }), + repository: z + .object({ + id: z.number().int().positive(), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1).optional(), + }) + .optional(), + repositories_added: z.array(z.object({ id: z.number().int().positive() })).optional(), + repositories_removed: z.array(z.object({ id: z.number().int().positive() })).optional(), +}) + +async function lastKnownGoodSeconds( + database: Prisma.TransactionClient | PrismaClient, +): Promise { + return ( + ( + await database.gitHubAuthorizationPolicy.findUnique({ + where: { id: policyId }, + select: { lastKnownGoodSeconds: true }, + }) + )?.lastKnownGoodSeconds ?? 86_400 + ) +} + +function authenticationDependencies(store: IdentityStore) { + return { + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(null) }, + now: () => new Date(), + } +} + +async function currentAdministrator( + store: IdentityStore, + sessionToken: string, + database?: Prisma.TransactionClient, +) { + const actor = await getCurrentSession( + authenticationDependencies(store), + sessionToken, + database ? createIdentityRecords(database) : store.read, + ) + if ( + actor.membership.role !== "administrator" || + !actor.freshAuthorizedAt || + Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000 + ) { + throw new AuthenticationError( + 403, + actor.membership.role === "administrator" + ? "fresh_authorization_required" + : "installation_administrator_required", + actor.membership.role === "administrator" + ? "Verify your identity again." + : "Installation administrator access is required.", + ) + } + return actor +} + +async function credentials( + store: IdentityStore, + provider: ReturnType, +): Promise { + const value = provider.read(await store.read.githubIntegrationState()) + if (!value) { + throw new AuthenticationError( + 503, + "source_integration_unconfigured", + "GitHub source integration is not configured.", + ) + } + return value +} + +async function permissionFor( + token: string, + username: string, + subject: string, + fullName: string, +): Promise { + try { + const response = collaboratorPermissionSchema.parse( + await requestGitHubApi( + `/repos/${fullName}/collaborators/${encodeURIComponent(username)}/permission`, + token, + ), + ) + if (String(response.user.id) !== subject) { + throw new AuthenticationError( + 403, + "identity_mismatch", + "The repository permission identity did not match.", + ) + } + return response.permission + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return "none" + throw error + } +} + +async function lockRepository(database: Prisma.TransactionClient, id: string): Promise { + await database.$queryRaw` + SELECT "id" FROM "github_repository_sources" WHERE "id" = ${id}::uuid FOR UPDATE + ` +} + +async function saveBaseline( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + requireAdmissionEnabled = false, +) { + await lockRepository(database, baseline.repositorySourceId) + const source = await database.gitHubRepositorySource.findUnique({ + where: { id: baseline.repositorySourceId }, + select: { status: true, admissionEnabled: true }, + }) + if ( + !source || + source.status !== "active" || + (requireAdmissionEnabled && !source.admissionEnabled) + ) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + await database.gitHubSynchronizedAccess.upsert({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + create: { + repositorySourceId: baseline.repositorySourceId, + userId, + rawPermission: baseline.rawPermission, + mappedPrototypeRole: baseline.prototypeRole, + admissionEligible: baseline.admissionEligible, + lastConfirmedAt: baseline.confirmedAt, + }, + update: { + rawPermission: baseline.rawPermission, + mappedPrototypeRole: baseline.prototypeRole, + admissionEligible: baseline.admissionEligible, + status: baseline.admissionEligible ? "active" : "removed", + synchronizationStatus: baseline.admissionEligible ? "current" : "removed", + lastConfirmedAt: baseline.confirmedAt, + removedAt: baseline.admissionEligible ? null : baseline.confirmedAt, + }, + }) +} + +async function enforceSynchronizedMemberships( + database: Prisma.TransactionClient, + at: Date, +): Promise { + const cutoff = new Date(at.getTime() - (await lastKnownGoodSeconds(database)) * 1_000) + const synchronizedMemberships = await database.installationMembership.findMany({ + where: { source: "synchronized", status: "active" }, + select: { id: true, userId: true }, + }) + for (const membership of synchronizedMemberships) { + const eligible = await database.gitHubSynchronizedAccess.findFirst({ + where: { + userId: membership.userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + }) + if (!eligible) { + await database.installationMembership.update({ + where: { id: membership.id }, + data: { status: "removed", removedAt: at }, + }) + await database.session.deleteMany({ where: { userId: membership.userId } }) + await saveSecurityAudit(database, { + event: "membership.synchronized.remove", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) + } + } +} + +async function removeRepositorySource( + database: Prisma.TransactionClient, + repositorySourceId: string, + removedAt: Date, +): Promise { + await database.gitHubRepositorySource.update({ + where: { id: repositorySourceId }, + data: { + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + removedAt, + }, + }) + await database.gitHubSynchronizedAccess.updateMany({ + where: { repositorySourceId }, + data: { status: "removed", synchronizationStatus: "removed", removedAt }, + }) + await enforceSynchronizedMemberships(database, removedAt) +} + +export function createGitHubAuthorizationService({ + database, + environment, + store, +}: { + database: PrismaClient + environment: NodeJS.ProcessEnv + store: IdentityStore +}): GitHubAuthorizationService { + const webhookSecret = environment.HUB_GITHUB_WEBHOOK_SECRET ?? null + if (webhookSecret && (webhookSecret.length < 20 || webhookSecret.length > 1024)) { + throw new Error("HUB_GITHUB_WEBHOOK_SECRET must contain between 20 and 1024 characters.") + } + + async function sourceCredentials() { + return credentials(store, createGitHubIntegrationCredentialProvider(environment)) + } + + async function reconcile(repositorySourceId: string, actorSessionToken?: string) { + const source = await database.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + const integration = await sourceCredentials() + if ( + integration.appId !== source.appId || + integration.installationId !== source.installationId + ) { + throw new AuthenticationError( + 409, + "repository_source_configuration_changed", + "The GitHub source integration configuration changed. Relink the repository.", + ) + } + const attemptedAt = new Date() + await database.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { lastAttemptedAt: attemptedAt, synchronizationStatus: "dirty", dirtyAt: attemptedAt }, + }) + try { + const token = await createInstallationToken(integration) + const repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), + ) + const identities = await database.externalIdentity.findMany({ + where: { provider: "github", issuer: "https://github.com", username: { not: null } }, + select: { userId: true, subject: true, username: true }, + }) + const confirmedAt = new Date() + const results = await Promise.all( + identities.map(async (identity) => { + const rawPermission = await permissionFor( + token, + identity.username!, + identity.subject, + repository.full_name, + ) + return { + userId: identity.userId, + rawPermission, + ...mapGitHubPermission(rawPermission), + } + }), + ) + await database.$transaction(async (transaction) => { + await lockRepository(transaction, source.id) + const actor = actorSessionToken + ? await currentAdministrator(store, actorSessionToken, transaction) + : null + await transaction.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + status: "active", + removedAt: null, + synchronizationStatus: "current", + synchronizationError: null, + dirtyAt: null, + lastConfirmedAt: confirmedAt, + }, + }) + for (const result of results) { + await saveBaseline(transaction, result.userId, { + repositorySourceId: source.id, + rawPermission: result.rawPermission, + admissionEligible: result.admissionEligible, + prototypeRole: result.prototypeRole, + confirmedAt, + }) + } + await enforceSynchronizedMemberships(transaction, confirmedAt) + await saveSecurityAudit(transaction, { + event: "github.repository.reconcile", + actorUserId: actor?.userId ?? null, + sessionId: actor?.id ?? null, + authenticationProviderRevisionId: actor?.authenticationProviderRevisionId ?? null, + githubRepositorySourceId: source.id, + context: { source: "github_app_installation" }, + }) + }) + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) { + await database.$transaction(async (transaction) => { + await lockRepository(transaction, source.id) + await removeRepositorySource(transaction, source.id, new Date()) + }) + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } + await database.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { + synchronizationStatus: "failed", + synchronizationError: + error instanceof GitHubApiError ? `github_${error.status}` : "provider_unavailable", + }, + }) + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + } + + return { + async evaluateAdmission(profile) { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active", admissionEnabled: true }, + orderBy: { id: "asc" }, + }) + if (sources.length === 0) return null + const identity = await database.externalIdentity.findUnique({ + where: { + provider_issuer_subject: { + provider: "github", + issuer: "https://github.com", + subject: profile.subject, + }, + }, + include: { user: { include: { memberships: true } } }, + }) + const cutoff = new Date(Date.now() - (await lastKnownGoodSeconds(database)) * 1_000) + const cached = identity + ? await database.gitHubSynchronizedAccess.findFirst({ + where: { + userId: identity.userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + orderBy: { lastConfirmedAt: "desc" }, + }) + : null + try { + const integration = await sourceCredentials() + const token = await createInstallationToken(integration) + for (const source of sources) { + if ( + source.appId !== integration.appId || + source.installationId !== integration.installationId + ) { + continue + } + const rawPermission = await permissionFor( + token, + profile.username, + profile.subject, + `${source.owner}/${source.name}`, + ) + const mapped = mapGitHubPermission(rawPermission) + if (mapped.admissionEligible) { + return { + repositorySourceId: source.id, + rawPermission, + ...mapped, + confirmedAt: new Date(), + } + } + } + return null + } catch (error) { + if (cached) { + const mapped = mapGitHubPermission(cached.rawPermission as GitHubRepositoryPermission) + return { + repositorySourceId: cached.repositorySourceId, + rawPermission: cached.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: cached.lastConfirmedAt, + } + } + const membership = identity?.user.memberships[0] + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + }, + + saveAdmissionBaseline: (transaction, userId, baseline) => + saveBaseline(transaction, userId, baseline, true), + + async prototypeBaseline( + transaction, + userId, + prototypeId, + now = new Date(), + ): Promise { + const prototype = await transaction.prototype.findUnique({ + where: { id: prototypeId }, + select: { + authorizationMode: true, + sourceRepositoryId: true, + }, + }) + if (prototype?.authorizationMode !== "github-synchronized" || !prototype.sourceRepositoryId) { + return null + } + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const baseline = await transaction.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: prototype.sourceRepositoryId, + userId, + }, + }, + include: { repositorySource: true }, + }) + if ( + !baseline || + baseline.status !== "active" || + !baseline.mappedPrototypeRole || + baseline.lastConfirmedAt < cutoff || + baseline.repositorySource.status !== "active" + ) { + return null + } + return { + role: baseline.mappedPrototypeRole, + repositorySourceId: baseline.repositorySourceId, + githubRepositoryId: baseline.repositorySource.githubRepositoryId, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + confirmedAt: baseline.lastConfirmedAt, + synchronizationStatus: baseline.repositorySource.synchronizationStatus, + } + }, + + async listRepositories(sessionToken) { + await currentAdministrator(store, sessionToken) + return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) + }, + + async linkRepository(sessionToken, input) { + const parsed = linkSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_repository_link", + "The repository link is invalid.", + ) + } + await currentAdministrator(store, sessionToken) + const integration = await sourceCredentials() + const token = await createInstallationToken(integration) + const repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${parsed.data.githubRepositoryId}`, token), + ) + return store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + const linked = await transaction.gitHubRepositorySource.upsert({ + where: { githubRepositoryId: String(repository.id) }, + create: { + githubRepositoryId: String(repository.id), + appId: integration.appId, + installationId: integration.installationId, + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + admissionEnabled: parsed.data.admissionEnabled, + dirtyAt: new Date(), + }, + update: { + appId: integration.appId, + installationId: integration.installationId, + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + status: "active", + removedAt: null, + admissionEnabled: parsed.data.admissionEnabled, + synchronizationStatus: "dirty", + dirtyAt: new Date(), + }, + }) + await saveSecurityAudit(transaction, { + event: "github.repository.link", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: linked.id, + context: { source: "github_app_installation" }, + }) + return linked + }) + }, + + async updateRepository(sessionToken, repositorySourceId, input) { + const parsed = updateSchema.safeParse(input) + if (!parsed.success || !identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 422, + "invalid_repository_policy", + "The repository policy is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + await lockRepository(transaction, repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + const updated = await transaction.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { admissionEnabled: parsed.data.admissionEnabled }, + }) + await saveSecurityAudit(transaction, { + event: "github.repository.policy", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: source.id, + context: { + reason: parsed.data.admissionEnabled ? "admission_enabled" : "admission_disabled", + }, + }) + return updated + }) + }, + + async unlinkRepository(sessionToken, repositorySourceId) { + if (!identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + await store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + await lockRepository(transaction, repositorySourceId) + const removedAt = new Date() + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + await removeRepositorySource(transaction, source.id, removedAt) + await saveSecurityAudit(transaction, { + event: "github.repository.unlink", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: source.id, + context: { source: "github_app_installation" }, + }) + }) + }, + + async reconcileRepository(sessionToken, repositorySourceId) { + await currentAdministrator(store, sessionToken) + if (!identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + return reconcile(repositorySourceId, sessionToken) + }, + + async reconcileDue() { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active" }, + select: { id: true }, + }) + for (const source of sources) { + try { + await reconcile(source.id) + } catch { + // The source record stores the explicit failure and bounded last-known-good state. + } + } + await database.$transaction((transaction) => + enforceSynchronizedMemberships(transaction, new Date()), + ) + }, + + async processWebhook({ body, deliveryId, event, signature }) { + if (!webhookSecret) { + throw new AuthenticationError( + 503, + "github_webhook_unconfigured", + "GitHub webhook verification is not configured.", + ) + } + const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}` + const valid = + signature.length === expected.length && + timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + if (!valid) { + throw new AuthenticationError( + 401, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + if (!/^[A-Za-z0-9._-]{1,128}$/.test(deliveryId) || !/^[a-z_]{1,64}$/.test(event)) { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + let decoded: unknown + try { + decoded = JSON.parse(body.toString("utf8")) + } catch { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + const payload = webhookSchema.safeParse(decoded) + if (!payload.success) { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + const integration = await sourceCredentials() + if (String(payload.data.installation.id) !== integration.installationId) return "ignored" + const relevant = new Set([ + "installation", + "installation_repositories", + "repository", + "member", + "membership", + "team_add", + ]) + if (!relevant.has(event)) return "ignored" + try { + return await database.$transaction(async (transaction) => { + const repositoryId = payload.data.repository?.id + const source = repositoryId + ? await transaction.gitHubRepositorySource.findUnique({ + where: { githubRepositoryId: String(repositoryId) }, + }) + : null + await transaction.gitHubWebhookDelivery.create({ + data: { + deliveryId, + event, + action: payload.data.action ?? null, + installationId: integration.installationId, + repositorySourceId: source?.id ?? null, + }, + }) + const now = new Date() + if ( + event === "installation" && + ["deleted", "suspend"].includes(payload.data.action ?? "") + ) { + const sources = await transaction.gitHubRepositorySource.findMany({ + where: { installationId: integration.installationId, status: "active" }, + select: { id: true }, + }) + for (const source of sources) { + await removeRepositorySource(transaction, source.id, now) + } + } else { + const dirtyIds = new Set() + const removedIds = new Set() + for (const repository of payload.data.repositories_added ?? []) + dirtyIds.add(String(repository.id)) + for (const repository of payload.data.repositories_removed ?? []) + removedIds.add(String(repository.id)) + if (repositoryId) dirtyIds.add(String(repositoryId)) + if (removedIds.size > 0) { + const removedSources = await transaction.gitHubRepositorySource.findMany({ + where: { githubRepositoryId: { in: [...removedIds] }, status: "active" }, + select: { id: true }, + }) + for (const source of removedSources) { + await removeRepositorySource(transaction, source.id, now) + } + } + await transaction.gitHubRepositorySource.updateMany({ + where: + dirtyIds.size > 0 + ? { githubRepositoryId: { in: [...dirtyIds] }, status: "active" } + : { installationId: integration.installationId, status: "active" }, + data: { synchronizationStatus: "dirty", dirtyAt: now }, + }) + } + await saveSecurityAudit(transaction, { + event: "github.webhook.process", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + githubRepositorySourceId: source?.id ?? null, + context: { source: "github_webhook" }, + }) + return "accepted" as const + }) + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as { code?: string }).code === "P2002" + ) { + return "duplicate" + } + throw error + } + }, + } +} diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts new file mode 100644 index 00000000..d9091158 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -0,0 +1,49 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { GitHubRepositoryPermission, MappedGitHubPermission } from "../map-permission/index.ts" + +export interface GitHubAdmissionBaseline extends MappedGitHubPermission { + repositorySourceId: string + rawPermission: GitHubRepositoryPermission + confirmedAt: Date +} + +export interface GitHubPrototypeBaseline { + role: NonNullable + repositorySourceId: string + githubRepositoryId: string + rawPermission: GitHubRepositoryPermission + confirmedAt: Date + synchronizationStatus: string +} + +export interface GitHubAuthorizationService { + evaluateAdmission(profile: GitHubUserProfile): Promise + saveAdmissionBaseline( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + ): Promise + prototypeBaseline( + database: Prisma.TransactionClient, + userId: string, + prototypeId: string, + now?: Date, + ): Promise + listRepositories(sessionToken: string): Promise + linkRepository(sessionToken: string, input: unknown): Promise + updateRepository( + sessionToken: string, + repositorySourceId: string, + input: unknown, + ): Promise + unlinkRepository(sessionToken: string, repositorySourceId: string): Promise + reconcileRepository(sessionToken: string, repositorySourceId: string): Promise + reconcileDue(): Promise + processWebhook(input: { + deliveryId: string + event: string + signature: string + body: Buffer + }): Promise<"accepted" | "duplicate" | "ignored"> +} diff --git a/hub/server/domains/github-authorization/map-permission/index.test.ts b/hub/server/domains/github-authorization/map-permission/index.test.ts new file mode 100644 index 00000000..4346a22d --- /dev/null +++ b/hub/server/domains/github-authorization/map-permission/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" + +import { mapGitHubPermission } from "#server/domains/github-authorization/map-permission" + +describe("mapGitHubPermission()", () => { + it.each([ + ["admin", "editor"], + ["maintain", "editor"], + ["write", "editor"], + ["triage", "commenter"], + ["read", "viewer"], + ] as const)("maps %s access to an admission-eligible %s role", (permission, prototypeRole) => { + expect(mapGitHubPermission(permission)).toEqual({ + admissionEligible: true, + prototypeRole, + }) + }) + + it("denies admission and prototype access when GitHub reports no permission", () => { + expect(mapGitHubPermission("none")).toEqual({ + admissionEligible: false, + prototypeRole: null, + }) + }) +}) diff --git a/hub/server/domains/github-authorization/map-permission/index.ts b/hub/server/domains/github-authorization/map-permission/index.ts new file mode 100644 index 00000000..cf93bb0f --- /dev/null +++ b/hub/server/domains/github-authorization/map-permission/index.ts @@ -0,0 +1,25 @@ +import type { PrototypeRole } from "#server/domains/prototype-authorization/evaluate-access/types" + +export type GitHubRepositoryPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none" + +export interface MappedGitHubPermission { + admissionEligible: boolean + prototypeRole: PrototypeRole | null +} + +export function mapGitHubPermission( + permission: GitHubRepositoryPermission, +): MappedGitHubPermission { + switch (permission) { + case "admin": + case "maintain": + case "write": + return { admissionEligible: true, prototypeRole: "editor" } + case "triage": + return { admissionEligible: true, prototypeRole: "commenter" } + case "read": + return { admissionEligible: true, prototypeRole: "viewer" } + case "none": + return { admissionEligible: false, prototypeRole: null } + } +} diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 741f1c4e..546ac9ec 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -1546,9 +1546,16 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) it("returns an empty terminal prototype page for a baseline-authorized user", async () => { - const baselineRole = vi.fn().mockResolvedValue("viewer") + const baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) const baseline: PrototypeBaselinePolicy = { - role: baselineRole, + baseline: baselineAccess, } const { prisma, service, store } = await database(baseline) const actor = await createActor(store, { @@ -1575,7 +1582,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { limit: 20, }), ).resolves.toEqual({ items: [], nextCursor: null }) - expect(baselineRole.mock.calls).toContainEqual([actor.user.id, prototype.id]) + expect(baselineAccess.mock.calls).toContainEqual([actor.user.id, prototype.id]) }) it("uses baseline authorization for single-prototype reads and role-gated mutations", async () => { @@ -1744,11 +1751,11 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) it("lets a workspace deny override prototype and baseline access when listing", async () => { - const baselineRole = vi - .fn() + const baselineAccess = vi + .fn() .mockRejectedValue(new Error("baseline unavailable")) const baseline: PrototypeBaselinePolicy = { - role: baselineRole, + baseline: baselineAccess, } const { prisma, service, store } = await database(baseline) const actor = await createActor(store, { @@ -1790,14 +1797,14 @@ describe("createPrototypeAuthorizationService() database behavior", () => { code: "prototype_access_denied", }) expect(prototypeScan).not.toHaveBeenCalled() - expect(baselineRole.mock.calls).toHaveLength(0) + expect(baselineAccess.mock.calls).toHaveLength(0) }) it("uses an explicit prototype role without consulting a failing baseline", async () => { - const baselineRole = vi - .fn() + const baselineAccess = vi + .fn() .mockRejectedValue(new Error("baseline unavailable")) - const { prisma, service, store } = await database({ role: baselineRole }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) const actor = await createActor(store, { subject: "explicit-role-baseline-failure", token: "explicit-role-baseline-failure-token", @@ -1843,7 +1850,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { "workspaceId", ].sort(), ) - expect(baselineRole.mock.calls).toHaveLength(0) + expect(baselineAccess.mock.calls).toHaveLength(0) }) it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index a7cfc2b2..cf10c6ed 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -33,6 +33,10 @@ const grantSchema = z.discriminatedUnion("effect", [ ]) const roles: PrototypeRole[] = ["viewer", "commenter", "editor", "administrator"] const identifierSchema = z.uuid() +const authorizationSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("hub-managed") }), + z.object({ mode: z.literal("github-synchronized"), repositorySourceId: z.uuid() }), +]) function authenticationDependencies(store: IdentityStore) { return { @@ -40,7 +44,7 @@ function authenticationDependencies(store: IdentityStore) { github: null, configuration: null, providerRevisionId: null, - admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(false) }, + admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(null) }, now: () => new Date(), } } @@ -166,6 +170,18 @@ async function lockPrototype( await database.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` } +async function lockGitHubRepositorySource( + database: Prisma.TransactionClient, + repositorySourceId: string, +): Promise { + await database.$queryRaw` + SELECT "id" + FROM "github_repository_sources" + WHERE "id" = ${repositorySourceId}::uuid + FOR UPDATE + ` +} + async function lockWorkspacePrototypes( database: Prisma.TransactionClient, workspaceId: string, @@ -337,10 +353,61 @@ async function prototypeAccessDecision( }) if (hubDecision.source !== "none" || !baseline) return hubDecision return evaluatePrototypeAccess({ - baselineRole: await baseline.role(userId, prototype.id), + baseline: await baseline.baseline(userId, prototype.id), }) } +async function readAccessibleWorkspacePage({ + baseline, + database, + userId, + cursor, + limit, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + cursor?: string + limit: number +}) { + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.workspace.findMany({ + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + const workspaceGrant = candidate.grants[0] ?? null + if ( + evaluatePrototypeAccess({ workspaceGrant }).allowed || + (await hasAnyPrototypeAccess({ + baseline, + database, + userId, + workspaceId: candidate.id, + workspaceGrant, + })) + ) { + items.push({ + id: candidate.id, + createdByUserId: candidate.createdByUserId, + name: candidate.name, + createdAt: candidate.createdAt, + updatedAt: candidate.updatedAt, + }) + } + if (items.length > limit) break + } + if (candidates.length < 100) break + } + return pageResult(items, limit) +} + async function readAccessiblePrototypePage({ baseline, database, @@ -467,25 +534,13 @@ export function createPrototypeAuthorizationService({ async listWorkspaces(sessionToken, query) { const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) - const items = await database.workspace.findMany({ - where: { - ...(cursor ? { id: { gt: cursor } } : {}), - grants: { - some: { userId: actor.userId, effect: "allow" }, - none: { userId: actor.userId, effect: "deny" }, - }, - }, - orderBy: { id: "asc" }, - take: limit + 1, - select: { - id: true, - createdByUserId: true, - name: true, - createdAt: true, - updatedAt: true, - }, + return readAccessibleWorkspacePage({ + baseline, + database, + userId: actor.userId, + cursor, + limit, }) - return pageResult(items, limit) }, async createWorkspace(sessionToken, input) { @@ -721,6 +776,7 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, + baseline, ) requireDecision(decision, "editor") if (prototype.archivedAt) { @@ -749,6 +805,64 @@ export function createPrototypeAuthorizationService({ return decision }, + async setPrototypeAuthorization(sessionToken, prototypeId, input) { + const parsed = authorizationSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_authorization_policy", + "The prototype authorization policy is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + baseline, + ) + requireDecision(decision, "administrator") + if (parsed.data.mode === "github-synchronized") { + await lockGitHubRepositorySource(transaction, parsed.data.repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: parsed.data.repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 422, + "repository_source_unavailable", + "The linked GitHub repository is unavailable.", + ) + } + } + const updated = await transaction.prototype.update({ + where: { id: prototypeId }, + data: + parsed.data.mode === "hub-managed" + ? { authorizationMode: "hub-managed", sourceRepositoryId: null } + : { + authorizationMode: "github-synchronized", + sourceRepositoryId: parsed.data.repositorySourceId, + }, + }) + await audit(transaction, actor, { + event: "prototype.authorization.policy", + workspaceId: prototype.workspaceId, + prototypeId, + githubRepositorySourceId: + parsed.data.mode === "github-synchronized" ? parsed.data.repositorySourceId : null, + context: { + reason: parsed.data.mode === "hub-managed" ? "hub_managed" : "github_synchronized", + }, + }) + return updated + }) + }, + async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { const change = parseGrant(input) const targetId = normalizeIdentifier( @@ -766,6 +880,7 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, + baseline, ) requireDecision(decision, "administrator") await requireActiveTarget(transaction, targetId) @@ -808,6 +923,7 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, + baseline, ) requireDecision(decision, "administrator") if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, null))) { @@ -861,6 +977,7 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, + baseline, ) requireDecision(decision, "editor") if (prototype.archivedAt) { diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index fc197973..8419000a 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -1,4 +1,4 @@ -import type { AccessDecision, PrototypeRole } from "../evaluate-access/types.ts" +import type { AccessDecision, AccessInput, PrototypeRole } from "../evaluate-access/types.ts" export interface Page { items: T[] @@ -24,6 +24,11 @@ export interface PrototypeAuthorizationService { createPrototype(sessionToken: string, workspaceId: string, input: unknown): Promise getPrototype(sessionToken: string, prototypeId: string): Promise archivePrototype(sessionToken: string, prototypeId: string): Promise + setPrototypeAuthorization( + sessionToken: string, + prototypeId: string, + input: unknown, + ): Promise explainPrototypeAccess(sessionToken: string, prototypeId: string): Promise setPrototypeAccess( sessionToken: string, @@ -47,5 +52,5 @@ export interface GrantInput { } export interface PrototypeBaselinePolicy { - role(userId: string, prototypeId: string): Promise + baseline(userId: string, prototypeId: string): Promise } diff --git a/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts index e3fab4a9..96a73a58 100644 --- a/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts @@ -2,13 +2,22 @@ import { describe, expect, it } from "vitest" import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" +const baseline = { + role: "editor" as const, + repositorySourceId: "repository-source", + githubRepositoryId: "123", + rawPermission: "write", + confirmedAt: new Date("2026-10-27T20:00:00.000Z"), + synchronizationStatus: "current", +} + describe("evaluatePrototypeAccess()", () => { it("uses a prototype role even when it restricts a stronger workspace role", () => { expect( evaluatePrototypeAccess({ workspaceGrant: { effect: "allow", role: "administrator" }, prototypeGrant: { effect: "allow", role: "viewer" }, - baselineRole: "editor", + baseline, }), ).toEqual({ allowed: true, @@ -36,7 +45,7 @@ describe("evaluatePrototypeAccess()", () => { evaluatePrototypeAccess({ workspaceGrant: input.workspaceGrant, prototypeGrant: input.prototypeGrant, - baselineRole: "administrator", + baseline: { ...baseline, role: "administrator" }, }), ).toEqual({ allowed: false, @@ -46,11 +55,24 @@ describe("evaluatePrototypeAccess()", () => { }) }) - it("orders workspace access before a future baseline", () => { + it("orders a prototype role before a workspace role before the GitHub baseline", () => { + expect( + evaluatePrototypeAccess({ + prototypeGrant: { effect: "allow", role: "viewer" }, + workspaceGrant: { effect: "allow", role: "commenter" }, + baseline, + }), + ).toEqual({ + allowed: true, + role: "viewer", + source: "hub-prototype", + reason: "prototype_role", + }) + expect( evaluatePrototypeAccess({ workspaceGrant: { effect: "allow", role: "commenter" }, - baselineRole: "administrator", + baseline: { ...baseline, role: "administrator" }, }), ).toEqual({ allowed: true, @@ -59,11 +81,18 @@ describe("evaluatePrototypeAccess()", () => { reason: "workspace_role", }) - expect(evaluatePrototypeAccess({ baselineRole: "editor" })).toEqual({ + expect(evaluatePrototypeAccess({ baseline })).toEqual({ allowed: true, role: "editor", - source: "baseline", - reason: "baseline_role", + source: "github-synchronized", + reason: "github_baseline", + baseline: { + repositorySourceId: "repository-source", + githubRepositoryId: "123", + rawPermission: "write", + confirmedAt: new Date("2026-10-27T20:00:00.000Z"), + synchronizationStatus: "current", + }, }) }) diff --git a/hub/server/domains/prototype-authorization/evaluate-access/index.ts b/hub/server/domains/prototype-authorization/evaluate-access/index.ts index 2d258e26..d4d9eac7 100644 --- a/hub/server/domains/prototype-authorization/evaluate-access/index.ts +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.ts @@ -6,7 +6,7 @@ import type { export function evaluatePrototypeAccess({ workspaceGrant, prototypeGrant, - baselineRole = null, + baseline = null, }: AccessInput): AccessDecision { if (prototypeGrant?.effect === "deny" || workspaceGrant?.effect === "deny") { return { @@ -32,12 +32,19 @@ export function evaluatePrototypeAccess({ reason: "workspace_role", } } - if (baselineRole) { + if (baseline) { return { allowed: true, - role: baselineRole, - source: "baseline", - reason: "baseline_role", + role: baseline.role, + source: "github-synchronized", + reason: "github_baseline", + baseline: { + repositorySourceId: baseline.repositorySourceId, + githubRepositoryId: baseline.githubRepositoryId, + rawPermission: baseline.rawPermission, + confirmedAt: baseline.confirmedAt, + synchronizationStatus: baseline.synchronizationStatus, + }, } } return { diff --git a/hub/server/domains/prototype-authorization/evaluate-access/types.ts b/hub/server/domains/prototype-authorization/evaluate-access/types.ts index 0ce29321..1e760fa4 100644 --- a/hub/server/domains/prototype-authorization/evaluate-access/types.ts +++ b/hub/server/domains/prototype-authorization/evaluate-access/types.ts @@ -8,12 +8,26 @@ export interface AccessGrant { export interface AccessDecision { allowed: boolean role: PrototypeRole | null - source: "hub-prototype" | "hub-workspace" | "baseline" | "none" - reason: "explicit_deny" | "prototype_role" | "workspace_role" | "baseline_role" | "no_access" + source: "hub-prototype" | "hub-workspace" | "github-synchronized" | "none" + reason: "explicit_deny" | "prototype_role" | "workspace_role" | "github_baseline" | "no_access" + baseline?: { + repositorySourceId: string + githubRepositoryId: string + rawPermission: string + confirmedAt: Date + synchronizationStatus: string + } } export interface AccessInput { workspaceGrant?: AccessGrant | null prototypeGrant?: AccessGrant | null - baselineRole?: PrototypeRole | null + baseline?: { + role: PrototypeRole + repositorySourceId: string + githubRepositoryId: string + rawPermission: string + confirmedAt: Date + synchronizationStatus: string + } | null } diff --git a/hub/server/index.ts b/hub/server/index.ts index af5b0cbc..ecf78b6b 100644 --- a/hub/server/index.ts +++ b/hub/server/index.ts @@ -10,6 +10,14 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) { } const authentication = await createAuthenticationRuntime(process.env) +const reconciliationInterval = setInterval(() => { + void authentication.githubAuthorization.reconcileDue().catch(() => { + log("error", "GitHub authorization reconciliation failed", { + reason: "reconciliation_failed", + }) + }) +}, 15 * 60_000) +reconciliationInterval.unref() createApp({ authentication }).listen(port, "127.0.0.1", () => { log("info", "Hub API listening", { host: "127.0.0.1", port }) diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts index 45291b89..d2576cf7 100644 --- a/hub/server/integrations/github/check-repository-admission/constants.ts +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -1,10 +1,26 @@ import { z } from "zod" export const installedRepositoriesSchema = z.object({ - repositories: z.array(z.object({ full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/) })), + repositories: z.array( + z.object({ + id: z.number().int().positive(), + full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1), + }), + ), }) export const collaboratorPermissionSchema = z.object({ user: z.object({ id: z.number().int().positive() }), permission: z.enum(["admin", "maintain", "write", "triage", "read", "none"]), }) + +export const repositorySchema = z.object({ + id: z.number().int().positive(), + full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1), +}) diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts index 6b63ca08..12c3a6dd 100644 --- a/hub/server/repositories/security-audits/save-audit/index.ts +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -28,6 +28,7 @@ export async function saveSecurityAudit(database: Prisma.TransactionClient, data prototypeId: data.prototypeId ?? null, prototypeVersionId: data.prototypeVersionId ?? null, targetUserId: data.targetUserId ?? null, + githubRepositorySourceId: data.githubRepositorySourceId ?? null, context, }, }) diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index fe15ab02..ef4c2ef7 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -10,6 +10,7 @@ export interface AuditInput { prototypeId?: string | null prototypeVersionId?: string | null targetUserId?: string | null + githubRepositorySourceId?: string | null context?: Record } @@ -26,6 +27,7 @@ export interface SecurityAuditRecord { prototypeId: string | null prototypeVersionId: string | null targetUserId: string | null + githubRepositorySourceId: string | null context: unknown createdAt: Date } diff --git a/hub/server/routes/github-authorization/index.test.ts b/hub/server/routes/github-authorization/index.test.ts new file mode 100644 index 00000000..0a2c3a0a --- /dev/null +++ b/hub/server/routes/github-authorization/index.test.ts @@ -0,0 +1,136 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" + +import { createApp } from "../../create-app/index.ts" + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const protectedHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "content-type": "application/json", + "x-csrf-token": csrfToken, +} + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { githubAuthorization: Mocked } { + const githubAuthorization = Object.assign( + { + evaluateAdmission: vi.fn(), + saveAdmissionBaseline: vi.fn(), + prototypeBaseline: vi.fn(), + listRepositories: vi.fn().mockResolvedValue([]), + linkRepository: vi.fn(), + updateRepository: vi.fn(), + unlinkRepository: vi.fn(), + reconcileRepository: vi.fn(), + reconcileDue: vi.fn(), + processWebhook: vi.fn(), + }, + overrides, + ) as Mocked + return { + service: {} as Mocked, + administration: {} as Mocked, + prototypeAuthorization: {} as Mocked, + githubAuthorization, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes) { + const server = createServer(createApp({ authentication })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe("GitHub authorization routes", () => { + it("passes the authenticated session to repository listing without accepting a body token", async () => { + const repository = { id: "repository-source" } + const listRepositories = vi.fn().mockResolvedValue([repository]) + const authentication = runtime({ listRepositories }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/github/repositories`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ repositories: [repository] }) + expect(listRepositories).toHaveBeenCalledWith(sessionToken) + }) + + it("rejects repository mutations before the service when CSRF does not match", async () => { + const linkRepository = vi.fn() + const authentication = runtime({ linkRepository }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/github/repositories`, { + method: "POST", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + body: JSON.stringify({ githubRepositoryId: "123" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(linkRepository).not.toHaveBeenCalled() + }) + + it.each([ + ["accepted", 202], + ["duplicate", 200], + ["ignored", 200], + ] as const)( + "forwards the verified webhook envelope and returns %s as HTTP %i", + async (result, status) => { + const body = Buffer.from('{"installation":{"id":7}}') + const processWebhook = vi.fn().mockResolvedValue(result) + const authentication = runtime({ processWebhook }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/github/webhooks`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-delivery": "delivery-1", + "x-github-event": "installation", + "x-hub-signature-256": "sha256=signature", + }, + body, + }) + + expect(response.status).toBe(status) + expect(await response.json()).toEqual({ result }) + expect(processWebhook).toHaveBeenCalledWith({ + deliveryId: "delivery-1", + event: "installation", + signature: "sha256=signature", + body, + }) + }, + ) +}) diff --git a/hub/server/routes/github-authorization/index.ts b/hub/server/routes/github-authorization/index.ts new file mode 100644 index 00000000..1af030cd --- /dev/null +++ b/hub/server/routes/github-authorization/index.ts @@ -0,0 +1,103 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +function sessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + return readCookie(request.headers.cookie, cookies.session) +} + +function mutationSessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + return readCookie(request.headers.cookie, cookies.session) +} + +function parameter(request: Request, name: string): string { + const value = request.params[name] + return typeof value === "string" ? value : "" +} + +export async function listRepositories( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + response.json({ + repositories: await authentication.githubAuthorization!.listRepositories( + sessionToken(authentication, request), + ), + }) +} + +export async function linkRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.linkRepository( + mutationSessionToken(authentication, request), + request.body, + ) + response.status(201).json({ repository }) +} + +export async function updateRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.updateRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + request.body, + ) + response.json({ repository }) +} + +export async function unlinkRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + await authentication.githubAuthorization!.unlinkRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + ) + response.status(204).end() +} + +export async function reconcileRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.reconcileRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + ) + response.json({ repository }) +} + +export async function processWebhook( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + const body = Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0) + const result = await authentication.githubAuthorization!.processWebhook({ + deliveryId: request.get("x-github-delivery") ?? "", + event: request.get("x-github-event") ?? "", + signature: request.get("x-hub-signature-256") ?? "", + body, + }) + response.status(result === "accepted" ? 202 : 200).json({ result }) +} diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index f9ad0ddd..1720c2cf 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -31,14 +31,26 @@ import { removePrototypeAccess, removeWorkspaceAccess, setPrototypeAccess, + setPrototypeAuthorization, setWorkspaceAccess, } from "#server/routes/product" +import { + linkRepository, + listRepositories, + processWebhook, + reconcileRepository, + unlinkRepository, + updateRepository, +} from "#server/routes/github-authorization" import type { AuthenticationRoutes } from "#server/routes/types" /** Keep endpoint registration separate from the app's shared middleware. */ export function routes(app: Express, authentication?: AuthenticationRoutes): void { app.get("/api/health", handleApiHealthGet) if (!authentication) return + if (authentication.githubAuthorization) { + app.post("/api/github/webhooks", processWebhook.bind(null, authentication)) + } app.use( ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], @@ -89,6 +101,22 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi "/api/administration/memberships/:id", handleApiAdministrationMembershipsPatch.bind(null, authentication), ) + if (authentication.githubAuthorization) { + app.get("/api/administration/github/repositories", listRepositories.bind(null, authentication)) + app.post("/api/administration/github/repositories", linkRepository.bind(null, authentication)) + app.patch( + "/api/administration/github/repositories/:repositorySourceId", + updateRepository.bind(null, authentication), + ) + app.delete( + "/api/administration/github/repositories/:repositorySourceId", + unlinkRepository.bind(null, authentication), + ) + app.post( + "/api/administration/github/repositories/:repositorySourceId/reconcile", + reconcileRepository.bind(null, authentication), + ) + } app.use("/api/administration", respondAdministrationError) const productMutationLimit = createRateLimit({ limit: 120, @@ -120,6 +148,11 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi productMutationLimit, archivePrototype.bind(null, authentication), ) + app.put( + "/api/prototypes/:prototypeId/authorization", + productMutationLimit, + setPrototypeAuthorization.bind(null, authentication), + ) app.get( "/api/prototypes/:prototypeId/access/explanation", explainPrototypeAccess.bind(null, authentication), diff --git a/hub/server/routes/product/index.ts b/hub/server/routes/product/index.ts index d3cac7a5..00d0ea2a 100644 --- a/hub/server/routes/product/index.ts +++ b/hub/server/routes/product/index.ts @@ -139,6 +139,19 @@ export async function archivePrototype( response.json({ prototype }) } +export async function setPrototypeAuthorization( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.setPrototypeAuthorization( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + request.body, + ) + response.json({ prototype }) +} + export async function explainPrototypeAccess( authentication: AuthenticationRoutes, request: Request, diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index b64ee8d9..530c61ce 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -4,10 +4,12 @@ import type { } from "#server/domains/authentication/create-service/types" import type { AdministrationService } from "#server/domains/administration/create-service/types" import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" export interface AuthenticationRoutes { service: AuthenticationService administration: AdministrationService prototypeAuthorization: PrototypeAuthorizationService + githubAuthorization?: GitHubAuthorizationService settings: AuthenticationSettings } From 9456999eae09d66a99a4ee9826aaeb2c78d72599 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 16:08:52 -0700 Subject: [PATCH 20/56] fix(hub): reject stale GitHub sync results Serialize reconciliation claims with source generations and scope outage fallback to the repository whose live permission check failed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/database.md | 2 +- hub/docs/github-integration.md | 4 +- hub/docs/testing-and-operations.md | 2 +- hub/schema/github-repository-source.prisma | 1 + .../migration.sql | 4 + .../create-service/index.db.test.ts | 363 ++++++++++++++++++ .../create-service/index.ts | 178 +++++++-- 7 files changed, 509 insertions(+), 45 deletions(-) diff --git a/hub/docs/database.md b/hub/docs/database.md index 6781af1f..c4dd111a 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -50,7 +50,7 @@ Workspace and prototype access use separate grant tables so each user has at mos GitHub synchronization uses separate records: -- `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, and synchronization timestamps do not define identity. +- `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, synchronization generation, and timestamps do not define identity. The generation is a compare-and-set boundary for rejecting stale in-flight reconciliation results. - `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. - `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. - `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index f3455675..23def02f 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -31,11 +31,13 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. + `POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository or installation removal marks synchronized records removed. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. ## Staleness and outages -A successful confirmation records `lastConfirmedAt`. Failed provider calls record a bounded failure status and leave that time unchanged. Hub permits a last-known-good baseline for one day, then fails closed. Reconciliation also removes synchronized memberships and sessions when no fresh admission-eligible source remains. +A successful confirmation records `lastConfirmedAt`. Failed provider calls record a bounded failure status and leave that time unchanged. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial for one source suppresses that source's old cache, while an outage may use only the still-fresh cache for the same source. Reconciliation also removes synchronized memberships and sessions when no fresh admission-eligible source remains. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 58c80f9b..1edcf5f6 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, repository rename and removal, installation removal, permission loss, provider outage, dirty webhooks, replayed deliveries, missed-event repair, manual override preservation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, repository rename and removal, installation removal, permission loss, provider outage, per-repository cache fallback, dirty webhooks, replayed deliveries, missed-event repair, stale overlapping reconciliation, unlink during reconciliation, manual override preservation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/schema/github-repository-source.prisma b/hub/schema/github-repository-source.prisma index cda1b3ef..6d64f4d0 100644 --- a/hub/schema/github-repository-source.prisma +++ b/hub/schema/github-repository-source.prisma @@ -17,6 +17,7 @@ model GitHubRepositorySource { defaultBranch String @map("default_branch") status String @default("active") admissionEnabled Boolean @default(false) @map("admission_enabled") + synchronizationGeneration Int @default(0) @map("synchronization_generation") synchronizationStatus String @default("dirty") @map("synchronization_status") synchronizationError String? @map("synchronization_error") removedAt DateTime? @map("removed_at") @db.Timestamptz(3) diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql index 1d328d76..f7118122 100644 --- a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -25,6 +25,7 @@ CREATE TABLE "github_repository_sources" ( "default_branch" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "admission_enabled" BOOLEAN NOT NULL DEFAULT false, + "synchronization_generation" INTEGER NOT NULL DEFAULT 0, "synchronization_status" TEXT NOT NULL DEFAULT 'dirty', "synchronization_error" TEXT, "removed_at" TIMESTAMPTZ(3), @@ -36,6 +37,9 @@ CREATE TABLE "github_repository_sources" ( CONSTRAINT "github_repository_sources_pkey" PRIMARY KEY ("id"), CONSTRAINT "github_repository_sources_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_repository_sources_synchronization_generation" CHECK ( + "synchronization_generation" >= 0 + ), CONSTRAINT "github_repository_sources_synchronization_status" CHECK ( "synchronization_status" IN ('dirty', 'current', 'failed', 'removed') ) diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 29fdede5..cb2676bd 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -105,6 +105,18 @@ function webhook(body: object, event: string, deliveryId: string) { } } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function requestUrl(input: string | URL | Request): string { + return typeof input === "string" ? input : input instanceof URL ? input.href : input.url +} + describe("createGitHubAuthorizationService() database behavior", () => { it("uses only a fresh baseline for the linked repository and keeps repositories isolated", async () => { const { prisma, service, store } = await database() @@ -358,6 +370,357 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("does not resurrect a repository removed while reconciliation is waiting on GitHub", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { userId: actor.user.id, effect: "allow", role: "commenter" }, + }, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const repositoryResponse = deferred() + const repositoryRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequested.resolve() + return repositoryResponse.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { + status: 200, + }), + ) + }), + ) + + const reconciliation = service.reconcileDue() + await repositoryRequested.promise + await service.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-during-reconcile", + ), + ) + repositoryResponse.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "resurrected-owner/resurrected-name", + name: "resurrected-name", + owner: { login: "resurrected-owner" }, + default_branch: "trunk", + }), + { status: 200 }, + ), + ) + await reconciliation + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + synchronizationGeneration: 2, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "commenter" }) + }) + + it("keeps the newer result when overlapping reconciliations finish out of order", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { userId: actor.user.id, effect: "deny", role: null }, + }, + }, + }) + const firstRepositoryResponse = deferred() + const secondRepositoryResponse = deferred() + const firstRepositoryRequested = deferred() + const secondRepositoryRequested = deferred() + let repositoryRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequests += 1 + if (repositoryRequests === 1) { + firstRepositoryRequested.resolve() + return firstRepositoryResponse.promise + } + secondRepositoryRequested.resolve() + return secondRepositoryResponse.promise + } + const permission = url.includes("newer-owner") ? "read" : "write" + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission }), { status: 200 }), + ) + }), + ) + + const olderReconciliation = service.reconcileDue() + await firstRepositoryRequested.promise + const newerReconciliation = service.reconcileDue() + await secondRepositoryRequested.promise + secondRepositoryResponse.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "newer-owner/newer-name", + name: "newer-name", + owner: { login: "newer-owner" }, + default_branch: "newer-branch", + }), + { status: 200 }, + ), + ) + await newerReconciliation + firstRepositoryResponse.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "older-owner/older-name", + name: "older-name", + owner: { login: "older-owner" }, + default_branch: "older-branch", + }), + { status: 200 }, + ), + ) + await olderReconciliation + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: 2, + synchronizationStatus: "current", + owner: "newer-owner", + name: "newer-name", + defaultBranch: "newer-branch", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + mappedPrototypeRole: "viewer", + status: "active", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "deny", role: null }) + expect( + await prisma.securityAudit.count({ + where: { event: "github.repository.reconcile", githubRepositorySourceId: source.id }, + }), + ).toBe(1) + }) + + it("does not use one repository's cached grant when another repository fails", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const [sourceA, sourceB] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + id: "00000000-0000-0000-0000-000000000001", + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository-a", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + id: "00000000-0000-0000-0000-000000000002", + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository-b", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: sourceA.id, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.includes("repository-a")) { + return Promise.resolve(new Response(null, { status: 404 })) + } + if (url.includes("repository-b")) { + return Promise.resolve(new Response(null, { status: 503 })) + } + throw new Error(`Unexpected request: ${url}`) + }), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).resolves.toBeNull() + + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: sourceA.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ rawPermission: "write", status: "active" }) + expect(sourceB.id).not.toBe(sourceA.id) + }) + + it("uses a fresh cached grant only when that same repository's live check fails", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const confirmedAt = new Date() + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 503 }), + ), + ), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).resolves.toEqual({ + repositorySourceId: source.id, + rawPermission: "read", + admissionEligible: true, + prototypeRole: "viewer", + confirmedAt, + }) + }) + it("rolls back webhook mutations when the required revocation audit fails", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index f125a2ac..73b997a1 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -246,6 +246,7 @@ async function removeRepositorySource( data: { status: "removed", admissionEnabled: false, + synchronizationGeneration: { increment: 1 }, synchronizationStatus: "removed", removedAt, }, @@ -276,16 +277,30 @@ export function createGitHubAuthorizationService({ } async function reconcile(repositorySourceId: string, actorSessionToken?: string) { - const source = await database.gitHubRepositorySource.findUnique({ - where: { id: repositorySourceId }, + const source = await database.$transaction(async (transaction) => { + await lockRepository(transaction, repositorySourceId) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!current || current.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + const attemptedAt = new Date() + return transaction.gitHubRepositorySource.update({ + where: { id: current.id }, + data: { + synchronizationGeneration: { increment: 1 }, + lastAttemptedAt: attemptedAt, + synchronizationStatus: "dirty", + synchronizationError: null, + dirtyAt: attemptedAt, + }, + }) }) - if (!source || source.status !== "active") { - throw new AuthenticationError( - 404, - "repository_source_not_found", - "The repository link was not found.", - ) - } const integration = await sourceCredentials() if ( integration.appId !== source.appId || @@ -297,11 +312,6 @@ export function createGitHubAuthorizationService({ "The GitHub source integration configuration changed. Relink the repository.", ) } - const attemptedAt = new Date() - await database.gitHubRepositorySource.update({ - where: { id: source.id }, - data: { lastAttemptedAt: attemptedAt, synchronizationStatus: "dirty", dirtyAt: attemptedAt }, - }) try { const token = await createInstallationToken(integration) const repository = repositorySchema.parse( @@ -327,8 +337,18 @@ export function createGitHubAuthorizationService({ } }), ) - await database.$transaction(async (transaction) => { + const applied = await database.$transaction(async (transaction) => { await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return false + } const actor = actorSessionToken ? await currentAdministrator(store, actorSessionToken, transaction) : null @@ -364,24 +384,60 @@ export function createGitHubAuthorizationService({ githubRepositorySourceId: source.id, context: { source: "github_app_installation" }, }) + return true }) + if (!applied) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) } catch (error) { + if ( + error instanceof AuthenticationError && + [ + "fresh_authorization_required", + "installation_administrator_required", + "membership_inactive", + "sign_in_required", + ].includes(error.code) + ) { + throw error + } if (error instanceof GitHubApiError && error.status === 404) { - await database.$transaction(async (transaction) => { + const removed = await database.$transaction(async (transaction) => { await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return false + } await removeRepositorySource(transaction, source.id, new Date()) + return true }) + if (!removed) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) } - await database.gitHubRepositorySource.update({ - where: { id: source.id }, + const failed = await database.gitHubRepositorySource.updateMany({ + where: { + id: source.id, + status: "active", + synchronizationGeneration: source.synchronizationGeneration, + }, data: { synchronizationStatus: "failed", synchronizationError: error instanceof GitHubApiError ? `github_${error.status}` : "provider_unavailable", }, }) + if (failed.count === 0) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } if (error instanceof AuthenticationError) throw error throw new AuthenticationError( 503, @@ -410,27 +466,57 @@ export function createGitHubAuthorizationService({ }) const cutoff = new Date(Date.now() - (await lastKnownGoodSeconds(database)) * 1_000) const cached = identity - ? await database.gitHubSynchronizedAccess.findFirst({ + ? await database.gitHubSynchronizedAccess.findMany({ where: { userId: identity.userId, status: "active", admissionEligible: true, lastConfirmedAt: { gte: cutoff }, - repositorySource: { status: "active", admissionEnabled: true }, + repositorySourceId: { in: sources.map(({ id }) => id) }, }, - orderBy: { lastConfirmedAt: "desc" }, }) - : null + : [] + const cachedBySource = new Map( + cached.map((baseline) => [baseline.repositorySourceId, baseline]), + ) + const membership = identity?.user.memberships[0] + let integration: GitHubCredentials + let token: string try { - const integration = await sourceCredentials() - const token = await createInstallationToken(integration) + integration = await sourceCredentials() + token = await createInstallationToken(integration) + } catch (error) { + if (!(error instanceof GitHubApiError)) throw error for (const source of sources) { - if ( - source.appId !== integration.appId || - source.installationId !== integration.installationId - ) { - continue + const baseline = cachedBySource.get(source.id) + if (baseline) { + const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) + return { + repositorySourceId: source.id, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } } + } + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + let providerUnavailable = false + for (const source of sources) { + if ( + source.appId !== integration.appId || + source.installationId !== integration.installationId + ) { + continue + } + try { const rawPermission = await permissionFor( token, profile.username, @@ -446,29 +532,32 @@ export function createGitHubAuthorizationService({ confirmedAt: new Date(), } } - } - return null - } catch (error) { - if (cached) { - const mapped = mapGitHubPermission(cached.rawPermission as GitHubRepositoryPermission) - return { - repositorySourceId: cached.repositorySourceId, - rawPermission: cached.rawPermission as GitHubRepositoryPermission, - ...mapped, - confirmedAt: cached.lastConfirmedAt, + } catch (error) { + if (!(error instanceof GitHubApiError)) throw error + providerUnavailable = true + const baseline = cachedBySource.get(source.id) + if (baseline) { + const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) + return { + repositorySourceId: source.id, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } } } - const membership = identity?.user.memberships[0] + } + if (providerUnavailable) { if (membership?.status === "active" && membership.source !== "synchronized") { return null } - if (error instanceof AuthenticationError) throw error throw new AuthenticationError( 503, "source_integration_unavailable", "GitHub source integration is unavailable.", ) } + return null }, saveAdmissionBaseline: (transaction, userId, baseline) => @@ -562,6 +651,7 @@ export function createGitHubAuthorizationService({ status: "active", removedAt: null, admissionEnabled: parsed.data.admissionEnabled, + synchronizationGeneration: { increment: 1 }, synchronizationStatus: "dirty", dirtyAt: new Date(), }, @@ -787,7 +877,11 @@ export function createGitHubAuthorizationService({ dirtyIds.size > 0 ? { githubRepositoryId: { in: [...dirtyIds] }, status: "active" } : { installationId: integration.installationId, status: "active" }, - data: { synchronizationStatus: "dirty", dirtyAt: now }, + data: { + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: now, + }, }) } await saveSecurityAudit(transaction, { From 19b4ffe6f8bdf916dd9b41f87375dc41bd9f7b70 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 14:02:46 -0700 Subject: [PATCH 21/56] fix(hub): preserve synchronized access boundaries Serialize synchronized membership changes, reject stale admission writes, resolve repositories by immutable ID, and keep lower-precedence baselines isolated from explicit Hub decisions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/database.md | 2 + hub/docs/github-integration.md | 10 +- hub/docs/identity.md | 4 +- hub/docs/membership-and-administration.md | 2 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 61 +- .../administration/create-service/index.ts | 2 +- .../complete-github-authorization/index.ts | 7 +- .../authentication/create-runtime/index.ts | 6 +- .../create-service/index.db.test.ts | 72 ++ .../create-service/index.db.test.ts | 754 +++++++++++++++++- .../create-service/index.ts | 240 ++++-- .../create-service/types.ts | 10 +- .../create-service/index.db.test.ts | 201 ++++- .../create-service/index.ts | 60 +- .../create-service/types.ts | 11 +- .../check-repository-admission/constants.ts | 1 + .../installation-memberships/types.ts | 3 +- 18 files changed, 1345 insertions(+), 103 deletions(-) diff --git a/hub/docs/database.md b/hub/docs/database.md index c4dd111a..2817b95e 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -57,6 +57,8 @@ GitHub synchronization uses separate records: `Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. +Installation membership `source` distinguishes linked-source synchronization from the `legacy-synchronized` compatibility policy. Any administrator lifecycle change or assignment of Hub administration converts a synchronized membership to `manual`, so reconciliation cannot undo the Hub override or orphan administration. + ## Shared client Import `prisma` from `server/integrations/prisma/index.ts` rather than constructing clients in each caller. The integration uses Prisma's [documented global cache pattern](https://www.prisma.io/docs/orm/v7/more/troubleshooting/nextjs#avoiding-multiple-prisma-client-instances) to reuse one client across development module reloads. Production uses the module instance without setting the global variable. diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index 23def02f..a2010834 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -4,7 +4,7 @@ GitHub-synchronized authorization uses repository permissions as an opt-in basel ## Source identity and credentials -A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, active or removed state, admission switch, synchronization status, and timestamps. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. +A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, active or removed state, admission switch, synchronization status, and timestamps. Admission resolves the current owner and name from that immutable ID before checking a user permission, so a renamed repository cannot be confused with a replacement at its old name. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. All permission checks use an installation token created from the configured GitHub source-integration credentials. GitHub user authorization proves identity only. Hub does not retain or reuse the user's transient token for synchronization. @@ -25,19 +25,19 @@ Each prototype separately selects `hub-managed` or `github-synchronized`. The sy | `read` | Member | `viewer` | | `none` or missing | Denied | None | -GitHub does not map to Hub installation administrator or prototype administrator. Explicit Hub deny wins first, followed by explicit prototype and workspace roles, then the synchronized baseline. +GitHub does not map to Hub installation administrator or prototype administrator. GitHub's built-in `role_name` takes precedence over its legacy base `permission` field, so `role_name: triage` maps to commenter even when the base permission is `read`; unknown custom roles fall back to the base permission. Explicit Hub deny wins first, followed by explicit prototype and workspace roles, then the synchronized baseline. ## Reconciliation and webhooks `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Admission confirmations carry that same generation and cannot overwrite a newer reconciliation result. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository or installation removal marks synchronized records removed. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. ## Staleness and outages -A successful confirmation records `lastConfirmedAt`. Failed provider calls record a bounded failure status and leave that time unchanged. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial for one source suppresses that source's old cache, while an outage may use only the still-fresh cache for the same source. Reconciliation also removes synchronized memberships and sessions when no fresh admission-eligible source remains. +A successful confirmation records `lastConfirmedAt`. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 suppresses that source's old cache, while an outage may use only the still-fresh cache for the same source. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 8aa6018c..ea6deb7b 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -14,9 +14,9 @@ Hub sessions use a random opaque cookie and store only its digest. Authenticatio `HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. -`github-synchronized` is opt-in. It checks only repository source records whose admission switch is enabled, uses the separately configured GitHub App installation credentials, and records the confirmed raw permission and mapped baseline separately from the membership. A qualifying baseline creates or refreshes only a `synchronized` member assignment. Manual, invitation, bootstrap, recovery, suspended, and removed assignments remain Hub overrides and are never rewritten by synchronization. Permission loss removes a synchronized membership and its sessions only after reconciliation or the bounded last-known-good window expires. +`github-synchronized` is opt-in. It checks only repository source records whose admission switch is enabled, resolves their current metadata by immutable GitHub repository ID, uses the separately configured GitHub App installation credentials, and records the confirmed raw permission and mapped baseline separately from the membership. A qualifying baseline creates or refreshes only a `synchronized` member assignment. Manual, invitation, bootstrap, recovery, suspended, and administratively removed assignments remain Hub overrides and are never rewritten by synchronization. Permission loss removes a synchronized membership and its sessions only after reconciliation or the bounded last-known-good window expires. -`legacy-repository` preserves the previous compatibility behavior. It scans repositories available to the installation and creates a synchronized member assignment after finding any repository permission, without requiring linked source records. It is not the normal synchronized authorization mode. The GitHub authentication adapter itself never reads installation credentials or performs repository authorization. +`legacy-repository` preserves the previous compatibility behavior. It scans repositories available to the installation and creates a `legacy-synchronized` member assignment after finding any repository permission, without requiring linked source records. Linked-source reconciliation does not manage those compatibility assignments. It is not the normal synchronized authorization mode. The GitHub authentication adapter itself never reads installation credentials or performs repository authorization. Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index 6fd9c534..0ffb91bd 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -15,7 +15,7 @@ Each Hub user has at most one installation membership. Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. -Synchronization creates and refreshes only memberships whose source is `synchronized`. It never reactivates or rewrites a manual, invitation, bootstrap, recovery, or suspended membership. When no fresh admission-eligible baseline remains, reconciliation marks the synchronized membership removed and deletes its sessions in the same transaction. +Synchronization creates and refreshes only memberships whose source is `synchronized`. It never reactivates or rewrites a manual, invitation, bootstrap, recovery, suspended, or administratively removed membership. Administrator membership changes convert a synchronized assignment to `manual`. Assigning installation, workspace, or prototype administrator authority also makes the membership manual, because GitHub never owns Hub administration. When no fresh admission-eligible baseline remains, reconciliation marks only an ordinary synchronized member removed and deletes its sessions in the same serialized transaction. The compatibility policy uses `legacy-synchronized`, which linked-source reconciliation does not manage. Suspension and removal also check Hub-managed product administration in the same transaction. The change fails with `product_administration_orphaned` until another active user can administer every affected workspace and prototype. A role change from installation administrator to member does not remove explicit workspace or prototype roles. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 1edcf5f6..28c5f781 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, repository rename and removal, installation removal, permission loss, provider outage, per-repository cache fallback, dirty webhooks, replayed deliveries, missed-event repair, stale overlapping reconciliation, unlink during reconciliation, manual override preservation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, immutable-ID rename resolution, repository and installation removal, permission and identity mismatch, built-in role-name mapping, provider and credential outage, per-repository cache fallback, dirty and deletion webhooks, replayed deliveries, missed-event repair, bounded request concurrency, stale overlapping reconciliation, unlink during reconciliation, manual suspension and administration preservation, compatibility membership isolation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index 93ef76d1..d26d3292 100644 --- a/hub/server/domains/administration/create-service/index.db.test.ts +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -13,7 +13,15 @@ async function database() { return { prisma, store: createIdentityStore(prisma) } } -async function createAdministrator(store: IdentityStore, subject: string, token: string) { +async function createAdministrator( + store: IdentityStore, + subject: string, + token: string, + options: { + role?: "member" | "administrator" + source?: "manual" | "synchronized" + } = {}, +) { const authorizedAt = new Date("2026-10-27T18:00:00.000Z") return store.lock(async (records) => { const user = await records.createUser({ @@ -42,8 +50,8 @@ async function createAdministrator(store: IdentityStore, subject: string, token: const membership = await records.saveMembership({ userId: user.id, status: "active", - role: "administrator", - source: "manual", + role: options.role ?? "administrator", + source: options.source ?? "manual", suspendedAt: null, removedAt: null, }) @@ -428,4 +436,51 @@ describe("createAdministrationService() database behavior", () => { }) expect(await store.read.session(digestSecret("multi-scope-target-token"))).toBeNull() }) + + it("makes a synchronized membership manual when an administrator grants installation administration", async () => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const target = await createAdministrator(store, "target", "target-token", { + role: "member", + source: "synchronized", + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("actor-token", target.membership.id, { + role: "administrator", + }), + ).resolves.toMatchObject({ + role: "administrator", + source: "manual", + status: "active", + }) + }) + + it.each(["suspended", "removed"] as const)( + "makes an administrator's %s decision manual for a synchronized membership", + async (status) => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const target = await createAdministrator(store, "target", "target-token", { + role: "member", + source: "synchronized", + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("actor-token", target.membership.id, { status }), + ).resolves.toMatchObject({ + source: "manual", + status, + }) + expect(await store.read.session(digestSecret("target-token"))).toBeNull() + }, + ) }) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index 1edcfad9..4d642452 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -240,7 +240,7 @@ export function createAdministrationService( userId: target.userId, role: nextRole, status: nextStatus, - source: target.source, + source: "manual", suspendedAt: nextStatus === "suspended" ? dependencies.now() : null, removedAt: nextStatus === "removed" ? dependencies.now() : null, }) diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index fc72f566..db4da8c0 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -111,7 +111,10 @@ export async function completeGitHubAuthorization( let identity = await records.externalIdentity("github", configuration.issuer, profile.subject) let membership = identity ? await records.membership(identity.userId) : null const synchronizableMembership = - admissionBaseline !== null && (membership === null || membership.source === "synchronized") + admissionBaseline !== null && + membership?.status !== "suspended" && + (membership === null || + (membership.source === "synchronized" && membership.status === "removed")) const invitation = purpose === "sign-in" && membership?.status !== "active" && @@ -280,7 +283,7 @@ export async function completeGitHubAuthorization( userId: identity.userId, status: "active", role: "member", - source: "synchronized", + source: admissionBaseline === "legacy" ? "legacy-synchronized" : "synchronized", suspendedAt: null, removedAt: null, }) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 492d46e1..dcff7c00 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -93,8 +93,10 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv githubAuthorization, prototypeAuthorization: createPrototypeAuthorizationService({ baseline: { - baseline: (userId, prototypeId) => - githubAuthorization.prototypeBaseline(prisma, userId, prototypeId), + baseline: (database, userId, prototypeId) => + githubAuthorization.prototypeBaseline(database, userId, prototypeId), + workspaceIds: (database, userId) => + githubAuthorization.prototypeWorkspaceIds(database, userId), }, database: prisma, store, diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 2a2c7eca..ddfdebf6 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -65,6 +65,78 @@ async function createSession( } describe("createAuthenticationService() database behavior", () => { + it.each(["suspended", "removed"] as const)( + "does not reactivate a manually %s membership when synchronized admission succeeds", + async (status) => { + const { prisma, store } = await database() + const graph = await createAdministrator(store) + const now = new Date() + await store.lock(async (records) => { + await records.saveMembership({ + userId: graph.user.id, + status, + role: "member", + source: "manual", + suspendedAt: status === "suspended" ? now : null, + removedAt: status === "removed" ? now : null, + }) + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(now.getTime() + 60_000), + }) + }) + const saveBaseline = vi.fn() + const service = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: graph.identity.subject, + username: "octocat", + displayName: "Synthetic administrator", + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: graph.provider.id, + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + rawPermission: "read", + admissionEligible: true, + prototypeRole: "viewer", + confirmedAt: now, + }), + saveBaseline, + }, + now: () => now, + }) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect( + await prisma.installationMembership.findUnique({ where: { userId: graph.user.id } }), + ).toMatchObject({ source: "manual", status }) + expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(0) + expect(saveBaseline).not.toHaveBeenCalled() + }, + ) + it("rolls back generation, session deletion, and audit together when revocation fails", async () => { const { store } = await database() const graph = await createAdministrator(store) diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index cb2676bd..00bc4c5a 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -1,6 +1,8 @@ import { createHmac, generateKeyPairSync } from "node:crypto" import { describe, expect, it, onTestFinished, vi } from "vitest" +import { createAdministrationService } from "#server/domains/administration/create-service" +import { createAuthenticationService } from "#server/domains/authentication/create-service" import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" import { createIdentityStore } from "#server/domains/identity/create-store" import { digestSecret } from "#server/utils/digest-secret" @@ -45,10 +47,14 @@ async function createUser( { subject, membershipSource = "manual", + role = "member", + status = "active", token, }: { subject: string - membershipSource?: "manual" | "synchronized" + membershipSource?: "legacy-synchronized" | "manual" | "synchronized" + role?: "administrator" | "member" + status?: "active" | "removed" | "suspended" token?: string }, ) { @@ -75,9 +81,11 @@ async function createUser( })) const membership = await records.saveMembership({ userId: user.id, - status: "active", - role: "member", + status, + role, source: membershipSource, + suspendedAt: status === "suspended" ? new Date() : null, + removedAt: status === "removed" ? new Date() : null, }) const session = token ? await records.createSession({ @@ -186,6 +194,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { const confirmedAt = new Date("2026-10-27T20:00:00.000Z") await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "write", prototypeRole: "editor", admissionEligible: true, @@ -245,6 +254,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -395,6 +405,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -626,6 +637,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { ]) await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: sourceA.id, + sourceGeneration: sourceA.synchronizationGeneration, rawPermission: "write", prototypeRole: "editor", admissionEligible: true, @@ -640,10 +652,33 @@ describe("createGitHubAuthorizationService() database behavior", () => { new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), ) } - if (url.includes("repository-a")) { - return Promise.resolve(new Response(null, { status: 404 })) + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository-a", + name: "repository-a", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + if (url.includes("/repos/owner/repository-a/")) { + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: 42 }, + permission: "none", + role_name: "none", + }), + { status: 200 }, + ), + ) } - if (url.includes("repository-b")) { + if (url.endsWith("/repositories/200")) { return Promise.resolve(new Response(null, { status: 503 })) } throw new Error(`Unexpected request: ${url}`) @@ -689,6 +724,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { const confirmedAt = new Date() await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -714,6 +750,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }), ).resolves.toEqual({ repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", admissionEligible: true, prototypeRole: "viewer", @@ -721,6 +758,710 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("rejects baseline saves from a stale source generation or older confirmation", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const confirmedAt = new Date("2026-10-28T18:00:00.000Z") + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt, + }) + const currentSource = await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { synchronizationGeneration: { increment: 1 } }, + }) + + await expect( + service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(confirmedAt.getTime() + 1_000), + }), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + await expect( + service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: currentSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(confirmedAt.getTime() - 1_000), + }), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + mappedPrototypeRole: "editor", + lastConfirmedAt: confirmedAt, + }) + }) + + it.each([ + { + permission: "read", + roleName: "triage", + rawPermission: "triage", + prototypeRole: "commenter", + }, + { + permission: "write", + roleName: "release-manager", + rawPermission: "write", + prototypeRole: "editor", + }, + ] as const)( + "resolves immutable repository metadata and maps role $roleName as $rawPermission", + async ({ permission, roleName, rawPermission, prototypeRole }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const request = vi.fn((input: string | URL | Request): Promise => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "renamed-owner/renamed-repository", + name: "renamed-repository", + owner: { login: "renamed-owner" }, + default_branch: "trunk", + }), + { status: 200 }, + ), + ) + } + if ( + url.endsWith("/repos/renamed-owner/renamed-repository/collaborators/octocat/permission") + ) { + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: 42 }, + permission, + role_name: roleName, + }), + { status: 200 }, + ), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + await expect( + service.evaluateAdmission({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }), + ).resolves.toMatchObject({ + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission, + prototypeRole, + admissionEligible: true, + }) + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/renamed-owner/renamed-repository/collaborators/octocat/permission", + ]) + }, + ) + + it("treats an immutable repository lookup 404 as definitive denial instead of cached access", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 404 }), + ), + ), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).resolves.toBeNull() + }) + + it("records source configuration mismatch as failed without refreshing confirmation", async () => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "99999", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "repository_source_configuration_changed", + lastConfirmedAt, + }) + }) + + it("records credential reconciliation failure without refreshing confirmation", async () => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "github_503", + lastConfirmedAt, + }) + }) + + it("relinks a repository without changing its existing admission switch", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "main", + admissionEnabled: true, + status: "removed", + removedAt: new Date(), + }, + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 100, + full_name: "new-owner/new-name", + name: "new-name", + owner: { login: "new-owner" }, + default_branch: "trunk", + }), + { status: 200 }, + ), + ), + ) + + await expect( + service.linkRepository("administrator-token", { githubRepositoryId: "100" }), + ).resolves.toMatchObject({ + id: source.id, + status: "active", + admissionEnabled: true, + owner: "new-owner", + name: "new-name", + }) + }) + + it("revokes synchronized access immediately for a deleted repository but preserves compatibility membership", async () => { + const { prisma, service, store } = await database() + const synchronized = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const compatibility = await createUser(store, { + subject: "43", + membershipSource: "legacy-synchronized", + token: "compatibility-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, synchronized.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + + await expect( + service.processWebhook( + webhook( + { + action: "deleted", + installation: { id: 7 }, + repository: { + id: 100, + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }, + }, + "repository", + "deleted-repository", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: synchronized.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect( + await prisma.installationMembership.findUnique({ + where: { id: compatibility.membership.id }, + }), + ).toMatchObject({ source: "legacy-synchronized", status: "active" }) + expect(await prisma.session.findUnique({ where: { id: synchronized.session!.id } })).toBeNull() + expect( + await prisma.session.findUnique({ where: { id: compatibility.session!.id } }), + ).not.toBeNull() + }) + + it("bounds permission requests and isolates an immutable identity mismatch to that identity", async () => { + const { prisma, service, store } = await database() + const users = [] + for (let index = 0; index < 12; index += 1) { + users.push(await createUser(store, { subject: String(1_000 + index) })) + } + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + let activeRequests = 0 + let maximumActiveRequests = 0 + let permissionRequestCount = 0 + const permissionGates: { resolve: () => void }[] = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + const subject = url.match(/collaborators\/user-(\d+)\/permission$/)?.[1] + if (!subject) throw new Error(`Unexpected request: ${url}`) + permissionRequestCount += 1 + activeRequests += 1 + maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests) + const gate = deferred() + permissionGates.push({ + resolve: () => { + activeRequests -= 1 + gate.resolve( + new Response( + JSON.stringify({ + user: { id: subject === "1000" ? 999_999 : Number(subject) }, + permission: "read", + }), + { status: 200 }, + ), + ) + }, + }) + return gate.promise + }), + ) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => { + expect(permissionRequestCount).toBe(10) + expect(activeRequests).toBe(10) + }) + expect(maximumActiveRequests).toBe(10) + permissionGates.slice(0, 10).forEach(({ resolve }) => resolve()) + await vi.waitFor(() => expect(permissionRequestCount).toBe(12)) + permissionGates.slice(10).forEach(({ resolve }) => resolve()) + await reconciliation + + expect(maximumActiveRequests).toBe(10) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: users[0]!.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + mappedPrototypeRole: null, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: users[1]!.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + admissionEligible: true, + mappedPrototypeRole: "viewer", + }) + }) + + it("does not overwrite an administrator suspension while reconciliation is waiting", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "target-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, target.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const targetPermission = deferred() + const targetPermissionRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + targetPermissionRequested.resolve() + return targetPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { + status: 200, + }), + ) + }), + ) + const administration = createAdministrationService({ store }) + + const reconciliation = service.reconcileDue() + await targetPermissionRequested.promise + await administration.changeMembership("administrator-token", target.membership.id, { + status: "suspended", + }) + targetPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + await reconciliation + + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "manual", status: "suspended" }) + expect(await prisma.session.findUnique({ where: { id: target.session!.id } })).toBeNull() + }) + + it("does not let an older reconciliation overwrite a fresh sign-in baseline", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const targetPermission = deferred() + const targetPermissionRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + targetPermissionRequested.resolve() + return targetPermission.promise + }), + ) + + const reconciliation = service.reconcileDue() + await targetPermissionRequested.promise + const currentSource = await prisma.gitHubRepositorySource.findUniqueOrThrow({ + where: { id: source.id }, + }) + const now = new Date() + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(now.getTime() + 60_000), + }), + ) + const freshBaseline = { + repositorySourceId: source.id, + sourceGeneration: currentSource.synchronizationGeneration, + rawPermission: "write" as const, + prototypeRole: "editor" as const, + admissionEligible: true, + confirmedAt: new Date(now.getTime() + 60_000), + } + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue(freshBaseline), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + now: () => now, + }) + + await expect(authentication.complete("code", "state", "browser-token")).resolves.toMatchObject({ + purpose: "sign-in", + }) + targetPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + await reconciliation + + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "active" }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + admissionEligible: true, + lastConfirmedAt: freshBaseline.confirmedAt, + }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(1) + }) + it("rolls back webhook mutations when the required revocation audit fails", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { @@ -741,6 +1482,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 73b997a1..874eaa94 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -32,7 +32,7 @@ const repositoryIdSchema = z.string().regex(/^[1-9]\d{0,19}$/) const identifierSchema = z.uuid() const linkSchema = z.object({ githubRepositoryId: repositoryIdSchema, - admissionEnabled: z.boolean().default(false), + admissionEnabled: z.boolean().optional(), }) const updateSchema = z.object({ admissionEnabled: z.boolean() }) const webhookSchema = z.object({ @@ -131,19 +131,37 @@ async function permissionFor( ), ) if (String(response.user.id) !== subject) { - throw new AuthenticationError( - 403, - "identity_mismatch", - "The repository permission identity did not match.", - ) + return "none" } - return response.permission + return normalizePermission(response.permission, response.role_name) } catch (error) { if (error instanceof GitHubApiError && error.status === 404) return "none" throw error } } +function normalizePermission( + permission: GitHubRepositoryPermission, + roleName?: string | null, +): GitHubRepositoryPermission { + if (roleName && ["admin", "maintain", "write", "triage", "read"].includes(roleName)) { + return roleName as GitHubRepositoryPermission + } + return permission +} + +async function mapWithConcurrency( + items: T[], + limit: number, + operation: (item: T) => Promise, +): Promise { + const results: R[] = [] + for (let index = 0; index < items.length; index += limit) { + results.push(...(await Promise.all(items.slice(index, index + limit).map(operation)))) + } + return results +} + async function lockRepository(database: Prisma.TransactionClient, id: string): Promise { await database.$queryRaw` SELECT "id" FROM "github_repository_sources" WHERE "id" = ${id}::uuid FOR UPDATE @@ -159,12 +177,17 @@ async function saveBaseline( await lockRepository(database, baseline.repositorySourceId) const source = await database.gitHubRepositorySource.findUnique({ where: { id: baseline.repositorySourceId }, - select: { status: true, admissionEnabled: true }, + select: { + status: true, + admissionEnabled: true, + synchronizationGeneration: true, + }, }) if ( !source || source.status !== "active" || - (requireAdmissionEnabled && !source.admissionEnabled) + (requireAdmissionEnabled && !source.admissionEnabled) || + source.synchronizationGeneration !== baseline.sourceGeneration ) { throw new AuthenticationError( 409, @@ -172,6 +195,22 @@ async function saveBaseline( "GitHub authorization changed. Start again.", ) } + const existing = await database.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + select: { lastConfirmedAt: true }, + }) + if (existing && existing.lastConfirmedAt > baseline.confirmedAt) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } await database.gitHubSynchronizedAccess.upsert({ where: { repositorySourceId_userId: { @@ -206,7 +245,7 @@ async function enforceSynchronizedMemberships( const cutoff = new Date(at.getTime() - (await lastKnownGoodSeconds(database)) * 1_000) const synchronizedMemberships = await database.installationMembership.findMany({ where: { source: "synchronized", status: "active" }, - select: { id: true, userId: true }, + select: { id: true, userId: true, role: true }, }) for (const membership of synchronizedMemberships) { const eligible = await database.gitHubSynchronizedAccess.findFirst({ @@ -219,10 +258,58 @@ async function enforceSynchronizedMemberships( }, }) if (!eligible) { - await database.installationMembership.update({ - where: { id: membership.id }, + const [workspaceAdministration, prototypeAdministration] = await Promise.all([ + database.workspaceAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + database.prototypeAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + ]) + if ( + membership.role === "administrator" || + workspaceAdministration || + prototypeAdministration + ) { + const preserved = await database.installationMembership.updateMany({ + where: { + id: membership.id, + source: "synchronized", + status: "active", + }, + data: { source: "manual" }, + }) + if (preserved.count > 0) { + await saveSecurityAudit(database, { + event: "membership.synchronized.preserve", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "manual_administration" }, + }) + } + continue + } + const removed = await database.installationMembership.updateMany({ + where: { + id: membership.id, + source: "synchronized", + status: "active", + }, data: { status: "removed", removedAt: at }, }) + if (removed.count === 0) continue await database.session.deleteMany({ where: { userId: membership.userId } }) await saveSecurityAudit(database, { event: "membership.synchronized.remove", @@ -241,6 +328,7 @@ async function removeRepositorySource( repositorySourceId: string, removedAt: Date, ): Promise { + await lockRepository(database, repositorySourceId) await database.gitHubRepositorySource.update({ where: { id: repositorySourceId }, data: { @@ -301,43 +389,51 @@ export function createGitHubAuthorizationService({ }, }) }) - const integration = await sourceCredentials() - if ( - integration.appId !== source.appId || - integration.installationId !== source.installationId - ) { - throw new AuthenticationError( - 409, - "repository_source_configuration_changed", - "The GitHub source integration configuration changed. Relink the repository.", - ) - } try { + const integration = await sourceCredentials() + if ( + integration.appId !== source.appId || + integration.installationId !== source.installationId + ) { + throw new AuthenticationError( + 409, + "repository_source_configuration_changed", + "The GitHub source integration configuration changed. Relink the repository.", + ) + } const token = await createInstallationToken(integration) const repository = repositorySchema.parse( await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), ) - const identities = await database.externalIdentity.findMany({ - where: { provider: "github", issuer: "https://github.com", username: { not: null } }, - select: { userId: true, subject: true, username: true }, - }) + const identities = [] + let identityCursor: string | undefined + while (true) { + const page = await database.externalIdentity.findMany({ + where: { provider: "github", issuer: "https://github.com", username: { not: null } }, + select: { id: true, userId: true, subject: true, username: true }, + orderBy: { id: "asc" }, + ...(identityCursor ? { cursor: { id: identityCursor }, skip: 1 } : {}), + take: 100, + }) + identities.push(...page) + if (page.length < 100) break + identityCursor = page.at(-1)?.id + } const confirmedAt = new Date() - const results = await Promise.all( - identities.map(async (identity) => { - const rawPermission = await permissionFor( - token, - identity.username!, - identity.subject, - repository.full_name, - ) - return { - userId: identity.userId, - rawPermission, - ...mapGitHubPermission(rawPermission), - } - }), - ) - const applied = await database.$transaction(async (transaction) => { + const results = await mapWithConcurrency(identities, 10, async (identity) => { + const rawPermission = await permissionFor( + token, + identity.username!, + identity.subject, + repository.full_name, + ) + return { + userId: identity.userId, + rawPermission, + ...mapGitHubPermission(rawPermission), + } + }) + const applied = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ where: { id: source.id }, @@ -369,6 +465,7 @@ export function createGitHubAuthorizationService({ for (const result of results) { await saveBaseline(transaction, result.userId, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: result.rawPermission, admissionEligible: result.admissionEligible, prototypeRole: result.prototypeRole, @@ -403,7 +500,7 @@ export function createGitHubAuthorizationService({ throw error } if (error instanceof GitHubApiError && error.status === 404) { - const removed = await database.$transaction(async (transaction) => { + const removed = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ where: { id: source.id }, @@ -432,7 +529,11 @@ export function createGitHubAuthorizationService({ data: { synchronizationStatus: "failed", synchronizationError: - error instanceof GitHubApiError ? `github_${error.status}` : "provider_unavailable", + error instanceof GitHubApiError + ? `github_${error.status}` + : error instanceof AuthenticationError + ? error.code + : "provider_unavailable", }, }) if (failed.count === 0) { @@ -493,6 +594,7 @@ export function createGitHubAuthorizationService({ const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) return { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: baseline.rawPermission as GitHubRepositoryPermission, ...mapped, confirmedAt: baseline.lastConfirmedAt, @@ -517,16 +619,26 @@ export function createGitHubAuthorizationService({ continue } try { + let repository: z.infer + try { + repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) continue + throw error + } const rawPermission = await permissionFor( token, profile.username, profile.subject, - `${source.owner}/${source.name}`, + repository.full_name, ) const mapped = mapGitHubPermission(rawPermission) if (mapped.admissionEligible) { return { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission, ...mapped, confirmedAt: new Date(), @@ -540,6 +652,7 @@ export function createGitHubAuthorizationService({ const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) return { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: baseline.rawPermission as GitHubRepositoryPermission, ...mapped, confirmedAt: baseline.lastConfirmedAt, @@ -608,6 +721,28 @@ export function createGitHubAuthorizationService({ } }, + async prototypeWorkspaceIds(transaction, userId, now = new Date()) { + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const prototypes = await transaction.prototype.findMany({ + where: { + authorizationMode: "github-synchronized", + sourceRepository: { + status: "active", + synchronizedAccess: { + some: { + userId, + status: "active", + mappedPrototypeRole: { not: null }, + lastConfirmedAt: { gte: cutoff }, + }, + }, + }, + }, + select: { workspaceId: true }, + }) + return [...new Set(prototypes.map(({ workspaceId }) => workspaceId))] + }, + async listRepositories(sessionToken) { await currentAdministrator(store, sessionToken) return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) @@ -639,7 +774,7 @@ export function createGitHubAuthorizationService({ owner: repository.owner.login, name: repository.name, defaultBranch: repository.default_branch, - admissionEnabled: parsed.data.admissionEnabled, + admissionEnabled: parsed.data.admissionEnabled ?? false, dirtyAt: new Date(), }, update: { @@ -650,7 +785,9 @@ export function createGitHubAuthorizationService({ defaultBranch: repository.default_branch, status: "active", removedAt: null, - admissionEnabled: parsed.data.admissionEnabled, + ...(parsed.data.admissionEnabled === undefined + ? {} + : { admissionEnabled: parsed.data.admissionEnabled }), synchronizationGeneration: { increment: 1 }, synchronizationStatus: "dirty", dirtyAt: new Date(), @@ -766,7 +903,7 @@ export function createGitHubAuthorizationService({ // The source record stores the explicit failure and bounded last-known-good state. } } - await database.$transaction((transaction) => + await store.lock((_records, transaction) => enforceSynchronizedMemberships(transaction, new Date()), ) }, @@ -827,7 +964,7 @@ export function createGitHubAuthorizationService({ ]) if (!relevant.has(event)) return "ignored" try { - return await database.$transaction(async (transaction) => { + return await store.lock(async (_records, transaction) => { const repositoryId = payload.data.repository?.id const source = repositoryId ? await transaction.gitHubRepositorySource.findUnique({ @@ -858,6 +995,9 @@ export function createGitHubAuthorizationService({ } else { const dirtyIds = new Set() const removedIds = new Set() + if (event === "repository" && payload.data.action === "deleted" && repositoryId) { + removedIds.add(String(repositoryId)) + } for (const repository of payload.data.repositories_added ?? []) dirtyIds.add(String(repository.id)) for (const repository of payload.data.repositories_removed ?? []) diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts index d9091158..faf87aad 100644 --- a/hub/server/domains/github-authorization/create-service/types.ts +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -1,9 +1,10 @@ import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" -import type { Prisma } from "../../../generated/prisma/client.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" import type { GitHubRepositoryPermission, MappedGitHubPermission } from "../map-permission/index.ts" export interface GitHubAdmissionBaseline extends MappedGitHubPermission { repositorySourceId: string + sourceGeneration: number rawPermission: GitHubRepositoryPermission confirmedAt: Date } @@ -25,11 +26,16 @@ export interface GitHubAuthorizationService { baseline: GitHubAdmissionBaseline, ): Promise prototypeBaseline( - database: Prisma.TransactionClient, + database: Prisma.TransactionClient | PrismaClient, userId: string, prototypeId: string, now?: Date, ): Promise + prototypeWorkspaceIds( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + now?: Date, + ): Promise listRepositories(sessionToken: string): Promise linkRepository(sessionToken: string, input: unknown): Promise updateRepository( diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 546ac9ec..a07c2af7 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -30,10 +30,12 @@ async function createActor( subject, token, role = "member", + source = "manual", }: { subject: string token: string role?: "member" | "administrator" + source?: "manual" | "synchronized" }, ) { const authorizedAt = new Date() @@ -65,7 +67,7 @@ async function createActor( userId: user.id, status: "active", role, - source: "manual", + source, }) const session = await records.createSession({ userId: user.id, @@ -1582,12 +1584,19 @@ describe("createPrototypeAuthorizationService() database behavior", () => { limit: 20, }), ).resolves.toEqual({ items: [], nextCursor: null }) - expect(baselineAccess.mock.calls).toContainEqual([actor.user.id, prototype.id]) + expect(baselineAccess.mock.calls).toContainEqual([prisma, actor.user.id, prototype.id]) }) it("uses baseline authorization for single-prototype reads and role-gated mutations", async () => { - const baselineRole = vi.fn().mockResolvedValue("administrator") - const { prisma, service, store } = await database({ role: baselineRole }) + const baselineAccess = vi.fn().mockResolvedValue({ + role: "administrator", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "admin", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) const actor = await createActor(store, { subject: "baseline-single-prototype", token: "baseline-single-prototype-token", @@ -1637,7 +1646,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ).resolves.toMatchObject({ allowed: true, role: "administrator", - source: "baseline", + source: "github-synchronized", }) await expect( service.listVersions("baseline-single-prototype-token", prototype.id, {}), @@ -1669,10 +1678,11 @@ describe("createPrototypeAuthorizationService() database behavior", () => { )) as { archivedAt: Date | null } expect(archived.archivedAt).toBeInstanceOf(Date) - expect(baselineRole.mock.calls.length).toBeGreaterThanOrEqual(8) + expect(baselineAccess.mock.calls.length).toBeGreaterThanOrEqual(8) expect( - baselineRole.mock.calls.every( - ([userId, prototypeId]) => userId === actor.user.id && prototypeId === prototype.id, + baselineAccess.mock.calls.every( + ([database, userId, prototypeId]) => + database && userId === actor.user.id && prototypeId === prototype.id, ), ).toBe(true) expect( @@ -1688,10 +1698,10 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) it("short-circuits the baseline for explicit prototype roles and denies", async () => { - const baselineRole = vi - .fn() + const baselineAccess = vi + .fn() .mockRejectedValue(new Error("baseline unavailable")) - const { prisma, service, store } = await database({ role: baselineRole }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) const actor = await createActor(store, { subject: "single-prototype-short-circuit", token: "single-prototype-short-circuit-token", @@ -1747,7 +1757,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { allowed: false, source: "hub-prototype", }) - expect(baselineRole).not.toHaveBeenCalled() + expect(baselineAccess).not.toHaveBeenCalled() }) it("lets a workspace deny override prototype and baseline access when listing", async () => { @@ -1853,6 +1863,173 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(baselineAccess.mock.calls).toHaveLength(0) }) + it("makes synchronized memberships manual when granting workspace or prototype administration", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "administration-actor", + token: "administration-actor-token", + role: "administrator", + }) + const workspaceTarget = await createActor(store, { + subject: "workspace-administrator-target", + token: "workspace-administrator-target-token", + source: "synchronized", + }) + const prototypeTarget = await createActor(store, { + subject: "prototype-administrator-target", + token: "prototype-administrator-target-token", + source: "synchronized", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + + await service.setWorkspaceAccess( + "administration-actor-token", + workspace.id, + workspaceTarget.user.id, + { effect: "allow", role: "administrator" }, + ) + await service.setPrototypeAccess( + "administration-actor-token", + prototype.id, + prototypeTarget.user.id, + { effect: "allow", role: "administrator" }, + ) + + expect( + await prisma.installationMembership.findUnique({ + where: { userId: workspaceTarget.user.id }, + }), + ).toMatchObject({ source: "manual", status: "active" }) + expect( + await prisma.installationMembership.findUnique({ + where: { userId: prototypeTarget.user.id }, + }), + ).toMatchObject({ source: "manual", status: "active" }) + }) + + it("passes the mutation transaction to a synchronized prototype baseline", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "editor", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "write", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + const actor = await createActor(store, { + subject: "transaction-baseline", + token: "transaction-baseline-token", + }) + const workspace = await prisma.workspace.create({ + data: { name: "Workspace", createdByUserId: actor.user.id }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + + await expect( + service.createVersion("transaction-baseline-token", prototype.id, { + label: "Baseline version", + }), + ).resolves.toMatchObject({ prototypeId: prototype.id, sequence: 1 }) + + expect(baselineAccess).toHaveBeenCalledOnce() + expect(baselineAccess.mock.calls[0]?.slice(1)).toEqual([actor.user.id, prototype.id]) + expect(baselineAccess.mock.calls[0]?.[0]).not.toBe(prisma) + }) + + it("does not evaluate prototype baselines in unrelated workspaces while listing workspaces", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const baselineWorkspaceIds = vi.fn>() + const { prisma, service, store } = await database({ + baseline: baselineAccess, + workspaceIds: baselineWorkspaceIds, + }) + const actor = await createActor(store, { + subject: "workspace-baseline-scope", + token: "workspace-baseline-scope-token", + }) + const accessibleWorkspace = await prisma.workspace.create({ + data: { + name: "Accessible workspace", + createdByUserId: actor.user.id, + prototypes: { + create: { + name: "Accessible prototype", + createdByUserId: actor.user.id, + }, + }, + }, + include: { prototypes: true }, + }) + await Promise.all( + Array.from({ length: 5 }, (_, index) => + prisma.workspace.create({ + data: { + name: `Unrelated workspace ${index}`, + createdByUserId: actor.user.id, + prototypes: { + create: { + name: `Unrelated prototype ${index}`, + createdByUserId: actor.user.id, + }, + }, + }, + }), + ), + ) + baselineWorkspaceIds.mockResolvedValue([accessibleWorkspace.id]) + + await expect( + service.listWorkspaces("workspace-baseline-scope-token", {}), + ).resolves.toMatchObject({ + items: [{ id: accessibleWorkspace.id }], + nextCursor: null, + }) + expect(baselineWorkspaceIds).toHaveBeenCalledWith(prisma, actor.user.id) + expect(baselineAccess.mock.calls).toEqual([ + [prisma, actor.user.id, accessibleWorkspace.prototypes[0]!.id], + ]) + }) + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index cf10c6ed..d04a4401 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -156,6 +156,18 @@ async function requireActiveTarget(database: Prisma.TransactionClient, userId: s } } +async function preserveManualAdministration( + database: Prisma.TransactionClient, + userId: string, + change: GrantInput, +): Promise { + if (change.effect !== "allow" || change.role !== "administrator") return + await database.installationMembership.updateMany({ + where: { userId, source: "synchronized", status: "active" }, + data: { source: "manual" }, + }) +} + async function lockWorkspace( database: Prisma.TransactionClient, workspaceId: string, @@ -231,14 +243,16 @@ async function prototypeDecision( if (!prototype) { throw new AuthenticationError(404, "prototype_not_found", "The prototype was not found.") } + const decision = await prototypeAccessDecision( + baseline, + database, + userId, + prototype.workspace.grants[0] ?? null, + prototype, + ) return { prototype, - decision: await prototypeAccessDecision( - baseline, - userId, - prototype.workspace.grants[0] ?? null, - prototype, - ), + decision, } } @@ -340,6 +354,7 @@ function prototypeDto(prototype: { async function prototypeAccessDecision( baseline: PrototypeBaselinePolicy | null, + database: Prisma.TransactionClient | PrismaClient, userId: string, workspaceGrant: GrantInput | null, prototype: { @@ -353,7 +368,7 @@ async function prototypeAccessDecision( }) if (hubDecision.source !== "none" || !baseline) return hubDecision return evaluatePrototypeAccess({ - baseline: await baseline.baseline(userId, prototype.id), + baseline: await baseline.baseline(database, userId, prototype.id), }) } @@ -370,10 +385,20 @@ async function readAccessibleWorkspacePage({ cursor?: string limit: number }) { + const baselineWorkspaceIds = baseline?.workspaceIds + ? await baseline.workspaceIds(database, userId) + : [] const items = [] let scanCursor = cursor while (items.length <= limit) { const candidates = await database.workspace.findMany({ + where: { + OR: [ + { grants: { some: { userId } } }, + { prototypes: { some: { grants: { some: { userId } } } } }, + ...(baselineWorkspaceIds.length > 0 ? [{ id: { in: baselineWorkspaceIds } }] : []), + ], + }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), @@ -452,7 +477,13 @@ async function readAccessiblePrototypePage({ if (candidates.length === 0) break for (const candidate of candidates) { scanCursor = candidate.id - const decision = await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate) + const decision = await prototypeAccessDecision( + baseline, + database, + userId, + workspaceGrant, + candidate, + ) if (decision.allowed) items.push(prototypeDto(candidate)) if (items.length > limit) break } @@ -496,7 +527,10 @@ async function hasAnyPrototypeAccess({ take: 100, }) for (const candidate of candidates) { - if ((await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate)).allowed) { + if ( + (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) + .allowed + ) { return true } } @@ -635,6 +669,7 @@ export function createPrototypeAuthorizationService({ create: { workspaceId, userId: targetId, ...change }, update: change, }) + await preserveManualAdministration(transaction, targetUserId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", workspaceId, @@ -776,7 +811,6 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "editor") if (prototype.archivedAt) { @@ -820,10 +854,10 @@ export function createPrototypeAuthorizationService({ requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") if (parsed.data.mode === "github-synchronized") { @@ -880,7 +914,6 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") await requireActiveTarget(transaction, targetId) @@ -896,6 +929,7 @@ export function createPrototypeAuthorizationService({ create: { prototypeId, userId: targetId, ...change }, update: change, }) + await preserveManualAdministration(transaction, targetId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant", workspaceId: prototype.workspaceId, @@ -923,7 +957,6 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, null))) { @@ -977,7 +1010,6 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "editor") if (prototype.archivedAt) { diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index 8419000a..13cfc46f 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -1,4 +1,5 @@ import type { AccessDecision, AccessInput, PrototypeRole } from "../evaluate-access/types.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" export interface Page { items: T[] @@ -52,5 +53,13 @@ export interface GrantInput { } export interface PrototypeBaselinePolicy { - baseline(userId: string, prototypeId: string): Promise + baseline( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + prototypeId: string, + ): Promise + workspaceIds?( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + ): Promise } diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts index d2576cf7..6d17ee27 100644 --- a/hub/server/integrations/github/check-repository-admission/constants.ts +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -15,6 +15,7 @@ export const installedRepositoriesSchema = z.object({ export const collaboratorPermissionSchema = z.object({ user: z.object({ id: z.number().int().positive() }), permission: z.enum(["admin", "maintain", "write", "triage", "read", "none"]), + role_name: z.string().min(1).max(255).nullable().optional(), }) export const repositorySchema = z.object({ diff --git a/hub/server/repositories/installation-memberships/types.ts b/hub/server/repositories/installation-memberships/types.ts index 5823ef23..60a72238 100644 --- a/hub/server/repositories/installation-memberships/types.ts +++ b/hub/server/repositories/installation-memberships/types.ts @@ -2,7 +2,8 @@ export interface InstallationMembershipInput { userId: string status: "active" | "suspended" | "removed" role: "member" | "administrator" - source: "bootstrap" | "invitation" | "manual" | "recovery" | "synchronized" + source: + "bootstrap" | "invitation" | "legacy-synchronized" | "manual" | "recovery" | "synchronized" suspendedAt?: Date | null removedAt?: Date | null } From 427238568ffbea3a5065f60d3c3bd6faedb72da1 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 15:54:36 -0700 Subject: [PATCH 22/56] fix(hub): close synchronized authorization gaps Backfill compatibility memberships, persist negative confirmations, enforce synchronized session freshness, and align reconciliation, invitation, listing, and workspace-read behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 6 +- hub/docs/identity.md | 4 +- hub/docs/prototype-authorization.md | 2 +- hub/docs/testing-and-operations.md | 2 +- .../migration.sql | 18 + .../complete-github-authorization/index.ts | 5 +- .../create-service/index.db.test.ts | 67 ++++ .../create-service/index.test.ts | 106 +++++- .../get-current-session/index.ts | 6 + .../create-service/index.db.test.ts | 343 +++++++++++++----- .../create-service/index.ts | 142 +++++--- .../domains/identity/create-records/index.ts | 2 + .../domains/identity/create-store/types.ts | 1 + .../create-service/index.db.test.ts | 73 ++++ .../create-service/index.ts | 13 +- .../has-fresh-admission/index.ts | 27 ++ 16 files changed, 660 insertions(+), 157 deletions(-) create mode 100644 hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index a2010834..79fd44b3 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -37,7 +37,9 @@ Each reconciliation claims a monotonically increasing source generation while ho ## Staleness and outages -A successful confirmation records `lastConfirmedAt`. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 suppresses that source's old cache, while an outage may use only the still-fresh cache for the same source. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. +A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 suppresses that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. + +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. @@ -51,4 +53,4 @@ Manual Hub grants, denies, membership assignments, and suspensions are separate - `PUT /api/prototypes/:prototypeId/authorization` - `GET /api/prototypes/:prototypeId/access/explanation` -Administration mutations require an active installation administrator, fresh verification, the configured origin, and matching CSRF cookie and header. Product policy changes require a fresh effective prototype administrator. Request bodies are omitted from logs, responses are private and non-cacheable, and mutations write structured audits in the same transaction. +Administration mutations require an active installation administrator, fresh verification, the configured origin, and matching CSRF cookie and header. Repository listing requires the active administrator role but not a fresh verification because it is read-only. Product policy changes require a fresh effective prototype administrator. Request bodies are omitted from logs, responses are private and non-cacheable, and mutations write structured audits in the same transaction. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index ea6deb7b..d1f547d6 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -10,13 +10,13 @@ Hub stores a versioned authentication-provider revision with non-secret configur The callback consumes an attempt once, checks the same-browser binding and current provider and revocation state, exchanges the code with PKCE, and reads the authenticated GitHub profile. Hub maps GitHub's immutable numeric account ID to `ExternalIdentity.subject`. The user access token exists only while requesting that profile; Hub never stores it. -Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. +Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. Restoring a session for a `synchronized` membership also requires at least one fresh admission-eligible linked baseline, so expired access fails closed immediately rather than waiting for the periodic sweep. `HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. `github-synchronized` is opt-in. It checks only repository source records whose admission switch is enabled, resolves their current metadata by immutable GitHub repository ID, uses the separately configured GitHub App installation credentials, and records the confirmed raw permission and mapped baseline separately from the membership. A qualifying baseline creates or refreshes only a `synchronized` member assignment. Manual, invitation, bootstrap, recovery, suspended, and administratively removed assignments remain Hub overrides and are never rewritten by synchronization. Permission loss removes a synchronized membership and its sessions only after reconciliation or the bounded last-known-good window expires. -`legacy-repository` preserves the previous compatibility behavior. It scans repositories available to the installation and creates a `legacy-synchronized` member assignment after finding any repository permission, without requiring linked source records. Linked-source reconciliation does not manage those compatibility assignments. It is not the normal synchronized authorization mode. The GitHub authentication adapter itself never reads installation credentials or performs repository authorization. +`legacy-repository` preserves the previous compatibility behavior. It scans repositories available to the installation and creates a `legacy-synchronized` member assignment after finding any repository permission, without requiring linked source records. The migration backfills pre-existing synchronized compatibility memberships to that source before linked-source synchronization begins. Linked-source reconciliation does not manage those compatibility assignments. It is not the normal synchronized authorization mode. The GitHub authentication adapter itself never reads installation credentials or performs repository authorization. Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index ee0131f7..acad32eb 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -56,7 +56,7 @@ Workspace administrators cannot remove or replace the final active workspace adm Suspending or removing a membership fails with `product_administration_orphaned` when that user is the final active administrator for a workspace or prototype. An administrator must transfer product administration first. Changing an active installation administrator to a member does not remove an explicit product role, because installation and product authority remain separate. -A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. Workspace list responses contain only workspace fields and never embed prototype or access-rule metadata. +A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. A workspace reachable through a direct prototype rule or synchronized prototype baseline is also readable, unless an explicit workspace deny blocks it. Workspace list responses contain only workspace fields and never embed prototype or access-rule metadata. ## API and request boundaries diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 28c5f781..2c73197b 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, immutable-ID rename resolution, repository and installation removal, permission and identity mismatch, built-in role-name mapping, provider and credential outage, per-repository cache fallback, dirty and deletion webhooks, replayed deliveries, missed-event repair, bounded request concurrency, stale overlapping reconciliation, unlink during reconciliation, manual suspension and administration preservation, compatibility membership isolation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence, immutable-ID rename resolution, repository and installation removal, positive and negative permission confirmation, identity mismatch, built-in role-name mapping, provider and credential outage, App/installation-aware cache fallback, dirty and deletion webhooks, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, stale per-user and overlapping reconciliation, unlink during reconciliation, manual suspension and administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql index f7118122..b5941705 100644 --- a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -14,6 +14,24 @@ CREATE TABLE "github_authorization_policies" ( INSERT INTO "github_authorization_policies" ("id") VALUES ('00000000-0000-0000-0000-000000000001'); +ALTER TABLE "installation_memberships" +DROP CONSTRAINT "installation_memberships_source"; + +ALTER TABLE "installation_memberships" +ADD CONSTRAINT "installation_memberships_source" +CHECK ("source" IN ( + 'bootstrap', + 'invitation', + 'legacy-synchronized', + 'manual', + 'recovery', + 'synchronized' +)); + +UPDATE "installation_memberships" +SET "source" = 'legacy-synchronized' +WHERE "source" = 'synchronized'; + -- CreateTable CREATE TABLE "github_repository_sources" ( "id" UUID NOT NULL, diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index db4da8c0..bf6535f9 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -116,10 +116,7 @@ export async function completeGitHubAuthorization( (membership === null || (membership.source === "synchronized" && membership.status === "removed")) const invitation = - purpose === "sign-in" && - membership?.status !== "active" && - membership?.status !== "suspended" && - !admissionBaseline + purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) : null if ( diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index ddfdebf6..8e11196c 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -65,6 +65,73 @@ async function createSession( } describe("createAuthenticationService() database behavior", () => { + it("requires a fresh eligible baseline only for synchronized sessions", async () => { + const { prisma, store } = await database() + const graph = await createAdministrator(store) + await createSession(store, graph, "session-token") + await prisma.installationMembership.update({ + where: { userId: graph.user.id }, + data: { role: "member", source: "synchronized" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const baseline = await prisma.gitHubSynchronizedAccess.create({ + data: { + repositorySourceId: source.id, + userId: graph.user.id, + rawPermission: "read", + mappedPrototypeRole: "viewer", + admissionEligible: true, + lastConfirmedAt: new Date(), + }, + }) + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(null) }, + }) + + await expect(service.current("session-token")).resolves.toMatchObject({ + userId: graph.user.id, + membership: { source: "synchronized", status: "active" }, + }) + + await prisma.gitHubSynchronizedAccess.update({ + where: { id: baseline.id }, + data: { + admissionEligible: false, + mappedPrototypeRole: null, + rawPermission: "none", + status: "removed", + }, + }) + + await expect(service.current("session-token")).rejects.toMatchObject({ + status: 401, + code: "membership_inactive", + }) + + await prisma.installationMembership.update({ + where: { userId: graph.user.id }, + data: { source: "manual" }, + }) + await expect(service.current("session-token")).resolves.toMatchObject({ + userId: graph.user.id, + membership: { source: "manual", status: "active" }, + }) + }) + it.each(["suspended", "removed"] as const)( "does not reactivate a manually %s membership when synchronized admission succeeds", async (status) => { diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index b3f025c9..a27d5e53 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -249,7 +249,7 @@ describe("createAuthenticationService()", () => { ) }) - it("activates only an exact provider identity invitation before issuing a session", async () => { + it("uses an exact invitation before a qualifying synchronized baseline for an inactive identity", async () => { const existingIdentity = { id: "identity", userId: "user", @@ -300,10 +300,19 @@ describe("createAuthenticationService()", () => { freshAuthorizedAt: now, }), }) + const saveBaseline = vi.fn() const options = dependencies(identityRecords, { admissionPolicy: { - name: "hub-managed", - admit: vi.fn().mockResolvedValue(false), + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline, }, }) options.github.identify.mockResolvedValue({ @@ -333,6 +342,97 @@ describe("createAuthenticationService()", () => { "membership", now, ) + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + role: "administrator", + source: "invitation", + }), + ) + expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) + }) + + it("uses an invitation before a qualifying synchronized baseline for a new identity", async () => { + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "administrator" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const identity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation), + createUser: vi.fn().mockResolvedValue(identity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(identity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const saveBaseline = vi.fn() + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + rawPermission: "read", + admissionEligible: true, + prototypeRole: "viewer", + confirmedAt: now, + }), + saveBaseline, + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + role: "administrator", + source: "invitation", + }), + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) }) it("binds bootstrap enrollment to the configured one-use secret", async () => { diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts index e8a071d6..695e3c64 100644 --- a/hub/server/domains/authentication/get-current-session/index.ts +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -16,5 +16,11 @@ export async function getCurrentSession( if (!membership || membership.status !== "active") { throw new AuthenticationError(401, "membership_inactive", "Sign in to continue.") } + if ( + membership.source === "synchronized" && + !(await records.hasFreshGitHubAdmission(session.userId, dependencies.now())) + ) { + throw new AuthenticationError(401, "membership_inactive", "Sign in to continue.") + } return { ...session, membership } } diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 00bc4c5a..7a20a839 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -26,7 +26,7 @@ function environment() { } } -async function database() { +async function database(environmentOverrides: NodeJS.ProcessEnv = {}) { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") onTestFinished(() => prisma.$disconnect()) @@ -36,7 +36,7 @@ async function database() { store, service: createGitHubAuthorizationService({ database: prisma, - environment: environment(), + environment: { ...environment(), ...environmentOverrides }, store, }), } @@ -228,17 +228,31 @@ describe("createGitHubAuthorizationService() database behavior", () => { it("removes synchronized state for a repository webhook while preserving manual rules", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) - const source = await prisma.gitHubRepositorySource.create({ - data: { - githubRepositoryId: "100", - appId: "12345", - installationId: "7", - owner: "owner", - name: "repository", - defaultBranch: "main", - admissionEnabled: true, - }, - }) + const [source, unrelatedSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "unrelated", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt: new Date("2026-10-28T18:00:00.000Z"), + }, + }), + ]) const workspace = await prisma.workspace.create({ data: { name: "Workspace", @@ -295,6 +309,13 @@ describe("createGitHubAuthorizationService() database behavior", () => { where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, }), ).toMatchObject({ effect: "allow", role: "viewer" }) + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: unrelatedSource.id } }), + ).toMatchObject({ + synchronizationGeneration: unrelatedSource.synchronizationGeneration, + synchronizationStatus: "current", + dirtyAt: null, + }) expect(await prisma.gitHubWebhookDelivery.count()).toBe(1) }) @@ -487,9 +508,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ effect: "allow", role: "commenter" }) }) - it("keeps the newer result when overlapping reconciliations finish out of order", async () => { + it("does not start another scheduled reconciliation while one is running", async () => { const { prisma, service, store } = await database() - const actor = await createUser(store, { subject: "42" }) + await createUser(store, { subject: "42" }) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -501,19 +522,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - const workspace = await prisma.workspace.create({ - data: { - name: "Workspace", - createdByUserId: actor.user.id, - grants: { - create: { userId: actor.user.id, effect: "deny", role: null }, - }, - }, - }) - const firstRepositoryResponse = deferred() - const secondRepositoryResponse = deferred() - const firstRepositoryRequested = deferred() - const secondRepositoryRequested = deferred() + const repositoryResponse = deferred() + const repositoryRequested = deferred() let repositoryRequests = 0 vi.stubGlobal( "fetch", @@ -526,89 +536,49 @@ describe("createGitHubAuthorizationService() database behavior", () => { } if (url.endsWith("/repositories/100")) { repositoryRequests += 1 - if (repositoryRequests === 1) { - firstRepositoryRequested.resolve() - return firstRepositoryResponse.promise - } - secondRepositoryRequested.resolve() - return secondRepositoryResponse.promise + repositoryRequested.resolve() + return repositoryResponse.promise } - const permission = url.includes("newer-owner") ? "read" : "write" return Promise.resolve( - new Response(JSON.stringify({ user: { id: 42 }, permission }), { status: 200 }), + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), ) }), ) - const olderReconciliation = service.reconcileDue() - await firstRepositoryRequested.promise - const newerReconciliation = service.reconcileDue() - await secondRepositoryRequested.promise - secondRepositoryResponse.resolve( - new Response( - JSON.stringify({ - id: 100, - full_name: "newer-owner/newer-name", - name: "newer-name", - owner: { login: "newer-owner" }, - default_branch: "newer-branch", - }), - { status: 200 }, - ), - ) - await newerReconciliation - firstRepositoryResponse.resolve( + const firstReconciliation = service.reconcileDue() + await repositoryRequested.promise + + await expect(service.reconcileDue()).resolves.toBeUndefined() + expect(repositoryRequests).toBe(1) + + repositoryResponse.resolve( new Response( JSON.stringify({ - id: 100, - full_name: "older-owner/older-name", - name: "older-name", - owner: { login: "older-owner" }, - default_branch: "older-branch", + id: Number(source.githubRepositoryId), + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", }), { status: 200 }, ), ) - await olderReconciliation + await firstReconciliation expect( await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), ).toMatchObject({ - synchronizationGeneration: 2, synchronizationStatus: "current", - owner: "newer-owner", - name: "newer-name", - defaultBranch: "newer-branch", - }) - expect( - await prisma.gitHubSynchronizedAccess.findUnique({ - where: { - repositorySourceId_userId: { - repositorySourceId: source.id, - userId: actor.user.id, - }, - }, - }), - ).toMatchObject({ - rawPermission: "read", - mappedPrototypeRole: "viewer", - status: "active", + synchronizationGeneration: 1, }) - expect( - await prisma.workspaceAccessGrant.findUnique({ - where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, - }), - ).toMatchObject({ effect: "deny", role: null }) - expect( - await prisma.securityAudit.count({ - where: { event: "github.repository.reconcile", githubRepositorySourceId: source.id }, - }), - ).toBe(1) }) - it("does not use one repository's cached grant when another repository fails", async () => { + it("persists a live denial so a later outage cannot reuse the old positive cache", async () => { const { prisma, service, store } = await database() - const actor = await createUser(store, { subject: "42" }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) const [sourceA, sourceB] = await Promise.all([ prisma.gitHubRepositorySource.create({ data: { @@ -692,7 +662,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { displayName: actor.user.displayName, avatarUrl: null, }), - ).resolves.toBeNull() + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) expect( await prisma.gitHubSynchronizedAccess.findUnique({ @@ -703,8 +676,36 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }, }), - ).toMatchObject({ rawPermission: "write", status: "active" }) + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + mappedPrototypeRole: null, + status: "removed", + }) expect(sourceB.id).not.toBe(sourceA.id) + + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 503 }), + ), + ), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) }) it("uses a fresh cached grant only when that same repository's live check fails", async () => { @@ -758,6 +759,95 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it.each([ + { appId: "99999", installationId: "7", mismatch: "App" }, + { appId: "12345", installationId: "8", mismatch: "installation" }, + ])( + "does not use cached access after token creation fails when the source $mismatch ID differs", + async ({ appId, installationId }) => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId, + installationId, + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + }, + ) + + it.each([ + { membershipSource: "manual", expected: "preserved" }, + { membershipSource: "synchronized", expected: "closed" }, + ] as const)( + "fails $expected when stored source credentials cannot be decrypted for a $membershipSource membership", + async ({ membershipSource }) => { + const { prisma, service, store } = await database({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "database", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const actor = await createUser(store, { subject: "42", membershipSource }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await store.lock((records) => + records.saveGitHubIntegrationCredentials("invalid-encrypted-credentials"), + ) + const admission = service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + + if (membershipSource === "manual") { + await expect(admission).resolves.toBeNull() + } else { + await expect(admission).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + } + }, + ) + it("rejects baseline saves from a stale source generation or older confirmation", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) @@ -1018,6 +1108,33 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("lists repository sources for an administrator without fresh verification", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + await prisma.session.update({ + where: { id: administrator.session!.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 10 * 60_000) }, + }) + + await expect(service.listRepositories("administrator-token")).resolves.toEqual([ + expect.objectContaining({ id: source.id }), + ]) + }) + it("relinks a repository without changing its existing admission switch", async () => { const { prisma, service, store } = await database() await createUser(store, { @@ -1332,13 +1449,18 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.findUnique({ where: { id: target.session!.id } })).toBeNull() }) - it("does not let an older reconciliation overwrite a fresh sign-in baseline", async () => { + it("skips a superseded baseline while still applying another user's permission loss", async () => { const { prisma, service, store } = await database() const target = await createUser(store, { subject: "42", membershipSource: "synchronized", status: "removed", }) + const lost = await createUser(store, { + subject: "43", + membershipSource: "synchronized", + token: "lost-token", + }) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -1350,6 +1472,14 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) + await service.saveAdmissionBaseline(prisma, lost.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) const targetPermission = deferred() const targetPermissionRequested = deferred() vi.stubGlobal( @@ -1375,8 +1505,13 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } - targetPermissionRequested.resolve() - return targetPermission.promise + if (url.includes("user-42")) { + targetPermissionRequested.resolve() + return targetPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 43 }, permission: "none" }), { status: 200 }), + ) }), ) @@ -1460,6 +1595,24 @@ describe("createGitHubAuthorizationService() database behavior", () => { lastConfirmedAt: freshBaseline.confirmedAt, }) expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(1) + expect( + await prisma.installationMembership.findUnique({ where: { id: lost.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: lost.session!.id } })).toBeNull() + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: lost.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) }) it("rolls back webhook mutations when the required revocation audit fails", async () => { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 874eaa94..221e10ec 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -78,6 +78,7 @@ async function currentAdministrator( store: IdentityStore, sessionToken: string, database?: Prisma.TransactionClient, + requireFresh = true, ) { const actor = await getCurrentSession( authenticationDependencies(store), @@ -86,8 +87,8 @@ async function currentAdministrator( ) if ( actor.membership.role !== "administrator" || - !actor.freshAuthorizedAt || - Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000 + (requireFresh && + (!actor.freshAuthorizedAt || Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000)) ) { throw new AuthenticationError( 403, @@ -173,7 +174,8 @@ async function saveBaseline( userId: string, baseline: GitHubAdmissionBaseline, requireAdmissionEnabled = false, -) { + skipOlderConfirmation = false, +): Promise<"saved" | "superseded"> { await lockRepository(database, baseline.repositorySourceId) const source = await database.gitHubRepositorySource.findUnique({ where: { id: baseline.repositorySourceId }, @@ -205,6 +207,7 @@ async function saveBaseline( select: { lastConfirmedAt: true }, }) if (existing && existing.lastConfirmedAt > baseline.confirmedAt) { + if (skipOlderConfirmation) return "superseded" throw new AuthenticationError( 409, "github_authorization_changed", @@ -236,6 +239,7 @@ async function saveBaseline( removedAt: baseline.admissionEligible ? null : baseline.confirmedAt, }, }) + return "saved" } async function enforceSynchronizedMemberships( @@ -359,6 +363,7 @@ export function createGitHubAuthorizationService({ if (webhookSecret && (webhookSecret.length < 20 || webhookSecret.length > 1024)) { throw new Error("HUB_GITHUB_WEBHOOK_SECRET must contain between 20 and 1024 characters.") } + let reconciliationDueRunning = false async function sourceCredentials() { return credentials(store, createGitHubIntegrationCredentialProvider(environment)) @@ -463,14 +468,20 @@ export function createGitHubAuthorizationService({ }, }) for (const result of results) { - await saveBaseline(transaction, result.userId, { - repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, - rawPermission: result.rawPermission, - admissionEligible: result.admissionEligible, - prototypeRole: result.prototypeRole, - confirmedAt, - }) + await saveBaseline( + transaction, + result.userId, + { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: result.rawPermission, + admissionEligible: result.admissionEligible, + prototypeRole: result.prototypeRole, + confirmedAt, + }, + false, + true, + ) } await enforceSynchronizedMemberships(transaction, confirmedAt) await saveSecurityAudit(transaction, { @@ -587,17 +598,26 @@ export function createGitHubAuthorizationService({ integration = await sourceCredentials() token = await createInstallationToken(integration) } catch (error) { - if (!(error instanceof GitHubApiError)) throw error - for (const source of sources) { - const baseline = cachedBySource.get(source.id) - if (baseline) { - const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) - return { - repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, - rawPermission: baseline.rawPermission as GitHubRepositoryPermission, - ...mapped, - confirmedAt: baseline.lastConfirmedAt, + if (error instanceof GitHubApiError) { + for (const source of sources) { + if ( + source.appId !== integration!.appId || + source.installationId !== integration!.installationId + ) { + continue + } + const baseline = cachedBySource.get(source.id) + if (baseline) { + const mapped = mapGitHubPermission( + baseline.rawPermission as GitHubRepositoryPermission, + ) + return { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } } } } @@ -644,6 +664,17 @@ export function createGitHubAuthorizationService({ confirmedAt: new Date(), } } + if (identity) { + await store.lock((_records, transaction) => + saveBaseline(transaction, identity.userId, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission, + ...mapped, + confirmedAt: new Date(), + }), + ) + } } catch (error) { if (!(error instanceof GitHubApiError)) throw error providerUnavailable = true @@ -673,8 +704,9 @@ export function createGitHubAuthorizationService({ return null }, - saveAdmissionBaseline: (transaction, userId, baseline) => - saveBaseline(transaction, userId, baseline, true), + async saveAdmissionBaseline(transaction, userId, baseline) { + await saveBaseline(transaction, userId, baseline, true) + }, async prototypeBaseline( transaction, @@ -744,7 +776,7 @@ export function createGitHubAuthorizationService({ }, async listRepositories(sessionToken) { - await currentAdministrator(store, sessionToken) + await currentAdministrator(store, sessionToken, undefined, false) return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) }, @@ -892,20 +924,26 @@ export function createGitHubAuthorizationService({ }, async reconcileDue() { - const sources = await database.gitHubRepositorySource.findMany({ - where: { status: "active" }, - select: { id: true }, - }) - for (const source of sources) { - try { - await reconcile(source.id) - } catch { - // The source record stores the explicit failure and bounded last-known-good state. + if (reconciliationDueRunning) return + reconciliationDueRunning = true + try { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active" }, + select: { id: true }, + }) + for (const source of sources) { + try { + await reconcile(source.id) + } catch { + // The source record stores the explicit failure and bounded last-known-good state. + } } + await store.lock((_records, transaction) => + enforceSynchronizedMemberships(transaction, new Date()), + ) + } finally { + reconciliationDueRunning = false } - await store.lock((_records, transaction) => - enforceSynchronizedMemberships(transaction, new Date()), - ) }, async processWebhook({ body, deliveryId, event, signature }) { @@ -1012,17 +1050,25 @@ export function createGitHubAuthorizationService({ await removeRepositorySource(transaction, source.id, now) } } - await transaction.gitHubRepositorySource.updateMany({ - where: - dirtyIds.size > 0 - ? { githubRepositoryId: { in: [...dirtyIds] }, status: "active" } - : { installationId: integration.installationId, status: "active" }, - data: { - synchronizationGeneration: { increment: 1 }, - synchronizationStatus: "dirty", - dirtyAt: now, - }, - }) + if (dirtyIds.size > 0) { + await transaction.gitHubRepositorySource.updateMany({ + where: { githubRepositoryId: { in: [...dirtyIds] }, status: "active" }, + data: { + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: now, + }, + }) + } else if (removedIds.size === 0) { + await transaction.gitHubRepositorySource.updateMany({ + where: { installationId: integration.installationId, status: "active" }, + data: { + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: now, + }, + }) + } } await saveSecurityAudit(transaction, { event: "github.webhook.process", diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 1c3676b5..896f2e9b 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -6,6 +6,7 @@ import { createAuthorizationAttempt } from "#server/repositories/authorization-a 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 { hasFreshGitHubAdmission } from "#server/repositories/github-synchronized-access/has-fresh-admission" import { getGitHubIntegrationState } from "#server/repositories/github-integration-state/get-state" import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-state/save-credentials" import { advanceSessionGeneration } from "#server/repositories/identity-store-state/advance-session-generation" @@ -50,6 +51,7 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident githubIntegrationState: getGitHubIntegrationState.bind(null, database), saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), advanceGitHubIntegrationGeneration: advanceGitHubIntegrationGeneration.bind(null, database), + hasFreshGitHubAdmission: hasFreshGitHubAdmission.bind(null, database), user: getUser.bind(null, database), createUser: createUser.bind(null, database), updateUser: updateUser.bind(null, database), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 316eda4a..f6ca5760 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -48,6 +48,7 @@ export interface IdentityRecords { githubIntegrationState(): Promise saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise advanceGitHubIntegrationGeneration(): Promise + hasFreshGitHubAdmission(userId: string, now: Date): Promise user(id: string): Promise createUser(input: UserInput): Promise updateUser(id: string, input: UserInput): Promise diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index a07c2af7..b5b9fa75 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -2030,6 +2030,79 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ]) }) + it("gets a workspace through prototype or baseline access while an explicit workspace deny wins", async () => { + const baselineUsers = new Set() + const baselineAccess = vi + .fn() + .mockImplementation((_database, userId) => + Promise.resolve( + baselineUsers.has(userId) + ? { + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + } + : null, + ), + ) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + const prototypeOnly = await createActor(store, { + subject: "workspace-prototype-only", + token: "workspace-prototype-only-token", + }) + const synchronized = await createActor(store, { + subject: "workspace-synchronized", + token: "workspace-synchronized-token", + }) + const denied = await createActor(store, { + subject: "workspace-denied", + token: "workspace-denied-token", + }) + baselineUsers.add(synchronized.user.id) + baselineUsers.add(denied.user.id) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: prototypeOnly.user.id, + grants: { + create: { + userId: denied.user.id, + effect: "deny", + }, + }, + prototypes: { + create: { + name: "Prototype", + createdByUserId: prototypeOnly.user.id, + grants: { + create: { + userId: prototypeOnly.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }, + }, + }) + + await expect( + service.getWorkspace("workspace-prototype-only-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.getWorkspace("workspace-synchronized-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.getWorkspace("workspace-denied-token", workspace.id), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + }) + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index d04a4401..3bf4b8ae 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -614,7 +614,18 @@ export function createPrototypeAuthorizationService({ async getWorkspace(sessionToken, workspaceId) { const actor = await currentSession(store, sessionToken) const { workspace, decision } = await workspaceDecision(database, actor.userId, workspaceId) - requireDecision(decision, "viewer") + if ( + !decision.allowed && + !(await hasAnyPrototypeAccess({ + baseline, + database, + userId: actor.userId, + workspaceId, + workspaceGrant: workspace.grants[0] ?? null, + })) + ) { + requireDecision(decision, "viewer") + } return { id: workspace.id, createdByUserId: workspace.createdByUserId, diff --git a/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts b/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts new file mode 100644 index 00000000..122fc2c5 --- /dev/null +++ b/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts @@ -0,0 +1,27 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +const policyId = "00000000-0000-0000-0000-000000000001" + +export async function hasFreshGitHubAdmission( + database: Prisma.TransactionClient, + userId: string, + now: Date, +): Promise { + const policy = await database.gitHubAuthorizationPolicy.findUnique({ + where: { id: policyId }, + select: { lastKnownGoodSeconds: true }, + }) + const cutoff = new Date(now.getTime() - (policy?.lastKnownGoodSeconds ?? 86_400) * 1_000) + return ( + (await database.gitHubSynchronizedAccess.findFirst({ + where: { + userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + select: { id: true }, + })) !== null + ) +} From dceca17951bb05e3065a016bccabb9fedaf9241d Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 16:47:23 -0700 Subject: [PATCH 23/56] fix(hub): invalidate disproved GitHub grants Order live admission confirmations, revoke cached access on repository loss, preserve invitation authority during source outages, and harden webhook and installation lifecycle handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 6 +- hub/docs/testing-and-operations.md | 2 +- .../migration.sql | 4 +- .../complete-github-authorization/index.ts | 32 +- .../create-service/index.test.ts | 22 +- .../create-service/index.db.test.ts | 388 +++++++++++++++++- .../create-service/index.ts | 149 +++++-- .../create-service/index.ts | 2 +- 8 files changed, 557 insertions(+), 48 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index 79fd44b3..a4f1cc73 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -31,13 +31,13 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Admission confirmations carry that same generation and cannot overwrite a newer reconciliation result. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Each live admission permission request also claims its generation before calling GitHub, so an older response cannot overwrite a newer positive or negative confirmation. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation or admission attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. ## Staleness and outages -A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 suppresses that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. +A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 removes that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 2c73197b..d724f97a 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence, immutable-ID rename resolution, repository and installation removal, positive and negative permission confirmation, identity mismatch, built-in role-name mapping, provider and credential outage, App/installation-aware cache fallback, dirty and deletion webhooks, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, stale per-user and overlapping reconciliation, unlink during reconciliation, manual suspension and administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation, identity mismatch, built-in role-name mapping, provider and credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and deletion events, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql index b5941705..06cb6f3c 100644 --- a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -54,7 +54,9 @@ CREATE TABLE "github_repository_sources" ( "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "github_repository_sources_pkey" PRIMARY KEY ("id"), - CONSTRAINT "github_repository_sources_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_repository_sources_status" CHECK ( + "status" IN ('active', 'removed', 'suspended') + ), CONSTRAINT "github_repository_sources_synchronization_generation" CHECK ( "synchronization_generation" >= 0 ), diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index bf6535f9..29d828ee 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -48,13 +48,10 @@ export async function completeGitHubAuthorization( const purpose: AuthorizationPurpose = attempt.purpose let profile: GitHubUserProfile let admissionBaseline: Awaited> = null + let admissionFailure: AuthenticationError | null = null try { profile = await github.identify(code, attempt.pkceVerifier) - if (attempt.purpose === "sign-in") { - const admission = await admissionPolicy.admit(profile) - admissionBaseline = admission === true ? "legacy" : admission || null - } - } catch (error) { + } catch { await store.lock((records) => records.audit({ event: "authentication.provider_failed", @@ -64,13 +61,28 @@ export async function completeGitHubAuthorization( authenticationProviderRevisionId: providerRevisionId, }), ) - if (error instanceof AuthenticationError) throw error throw new AuthenticationError( 503, "authentication_provider_failed", "The authentication provider failed.", ) } + if (attempt.purpose === "sign-in") { + try { + const admission = await admissionPolicy.admit(profile) + admissionBaseline = admission === true ? "legacy" : admission || null + } catch (error) { + if (error instanceof AuthenticationError) { + admissionFailure = error + } else { + admissionFailure = new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + } + } const result = await store.lock(async (records, transaction) => { const [currentProvider, stateRecord] = await Promise.all([ records.currentAuthenticationProviderRevision("github"), @@ -119,6 +131,14 @@ export async function completeGitHubAuthorization( purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) : null + if ( + purpose === "sign-in" && + admissionFailure && + !(membership?.status === "active" && membership.source !== "synchronized") && + !invitation + ) { + throw admissionFailure + } if ( purpose === "sign-in" && membership?.status === "active" && diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index a27d5e53..a0184e0c 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -5,6 +5,7 @@ import { createAuthenticationService } from "#server/domains/authentication/crea import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" import type { GitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client/types" +import { AuthenticationError } from "#server/utils/authentication-error" import { digestSecret } from "#server/utils/digest-secret" const now = new Date("2026-10-27T18:00:00.000Z") @@ -352,7 +353,7 @@ describe("createAuthenticationService()", () => { expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) }) - it("uses an invitation before a qualifying synchronized baseline for a new identity", async () => { + it("uses an invitation for a new identity when synchronized admission is unavailable", async () => { const invitation = { id: "invitation", createdByUserId: "administrator", @@ -397,14 +398,15 @@ describe("createAuthenticationService()", () => { const options = dependencies(identityRecords, { admissionPolicy: { name: "github-synchronized", - admit: vi.fn().mockResolvedValue({ - repositorySourceId: "00000000-0000-0000-0000-000000000001", - sourceGeneration: 3, - rawPermission: "read", - admissionEligible: true, - prototypeRole: "viewer", - confirmedAt: now, - }), + admit: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ), + ), saveBaseline, }, }) @@ -432,7 +434,7 @@ describe("createAuthenticationService()", () => { "membership", now, ) - expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) + expect(saveBaseline).not.toHaveBeenCalled() }) it("binds bootstrap enrollment to the configured one-use secret", async () => { diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 7a20a839..989036a1 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -1,4 +1,5 @@ import { createHmac, generateKeyPairSync } from "node:crypto" +import { readFileSync } from "node:fs" import { describe, expect, it, onTestFinished, vi } from "vitest" import { createAdministrationService } from "#server/domains/administration/create-service" @@ -995,7 +996,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }), ).resolves.toMatchObject({ repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, + sourceGeneration: source.synchronizationGeneration + 1, rawPermission, prototypeRole, admissionEligible: true, @@ -1008,9 +1009,18 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, ) - it("treats an immutable repository lookup 404 as definitive denial instead of cached access", async () => { + it("invalidates a cached grant and session after an immutable repository 404", async () => { const { prisma, service, store } = await database() - const actor = await createUser(store, { subject: "42" }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + await createUser(store, { + subject: "43", + role: "administrator", + token: "administrator-token", + }) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -1049,6 +1059,71 @@ describe("createGitHubAuthorizationService() database behavior", () => { avatarUrl: null, }), ).resolves.toBeNull() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", admissionEligible: true }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ), + ) + await service.linkRepository("administrator-token", { + githubRepositoryId: source.githubRepositoryId, + admissionEnabled: true, + }) + + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 503 }), + ), + ), + ) + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) }) it("records source configuration mismatch as failed without refreshing confirmation", async () => { @@ -1187,6 +1262,44 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it.each([ + { + name: "an inaccessible repository", + response: new Response(null, { status: 404 }), + expected: { status: 422, code: "repository_source_inaccessible" }, + }, + { + name: "a provider authorization failure", + response: new Response(null, { status: 401 }), + expected: { status: 503, code: "source_integration_unavailable" }, + }, + { + name: "a malformed provider response", + response: new Response(JSON.stringify({ id: 100 }), { status: 200 }), + expected: { status: 503, code: "source_integration_unavailable" }, + }, + ])("returns a stable linking error for $name", async ({ response, expected }) => { + const { service, store } = await database() + await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce(response), + ) + + await expect( + service.linkRepository("administrator-token", { githubRepositoryId: "100" }), + ).rejects.toMatchObject(expected) + }) + it("revokes synchronized access immediately for a deleted repository but preserves compatibility membership", async () => { const { prisma, service, store } = await database() const synchronized = await createUser(store, { @@ -1364,6 +1477,135 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("keeps a newer denied sign-in authoritative when an older grant responds later", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock(async (records) => { + for (const suffix of ["older", "newer"]) { + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret(`${suffix}-state`), + browserDigest: digestSecret(`${suffix}-browser`), + pkceVerifier: `${suffix}-verifier`, + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }) + } + }) + const olderPermission = deferred() + const olderPermissionRequested = deferred() + let permissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + permissionRequests += 1 + if (permissionRequests === 1) { + olderPermissionRequested.resolve() + return olderPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const olderSignIn = authentication.complete("older-code", "older-state", "older-browser") + await olderPermissionRequested.promise + await expect( + authentication.complete("newer-code", "newer-state", "newer-browser"), + ).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + olderPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + await expect(olderSignIn).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + + expect(permissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + it("does not overwrite an administrator suspension while reconciliation is waiting", async () => { const { prisma, service, store } = await database() await createUser(store, { @@ -1615,6 +1857,146 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("rejects a non-ASCII signature with equal character length as unauthorized", async () => { + const { service } = await database() + const input = webhook({}, "ping", "non-ascii-signature") + const signature = `${input.signature.slice(0, -1)}รฉ` + expect(signature).toHaveLength(input.signature.length) + + await expect(service.processWebhook({ ...input, signature })).rejects.toMatchObject({ + status: 401, + code: "invalid_github_webhook", + }) + }) + + it("ignores a signed GitHub ping without an installation payload", async () => { + const { prisma, service } = await database({ + GITHUB_APP_ID: "", + GITHUB_INSTALLATION_ID: "", + GITHUB_PRIVATE_KEY: "", + }) + + await expect( + service.processWebhook( + webhook({ zen: "Keep it logically awesome.", hook_id: 1 }, "ping", "github-ping"), + ), + ).resolves.toBe("ignored") + expect(await prisma.gitHubWebhookDelivery.count()).toBe(0) + }) + + it("suspends linked sources without losing policy and resumes them dirty without baselines", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + + await expect( + service.processWebhook( + webhook( + { action: "suspend", installation: { id: 7 } }, + "installation", + "installation-suspend", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "suspended", + admissionEnabled: true, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + + await expect( + service.processWebhook( + webhook( + { action: "unsuspend", installation: { id: 7 } }, + "installation", + "installation-unsuspend", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "active", + admissionEnabled: true, + synchronizationStatus: "dirty", + synchronizationError: null, + removedAt: null, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: actor.user.id } })).toBe(0) + }) + + it("keeps the synchronized authorization migration constraints and compatibility backfill", () => { + const migration = readFileSync( + new URL( + "../../../../schema/migrations/20261027230000_github_synchronized_authorization/migration.sql", + import.meta.url, + ), + "utf8", + ) + + expect(migration).toMatch( + /CONSTRAINT "github_repository_sources_status" CHECK \(\s*"status" IN \('active', 'removed', 'suspended'\)\s*\)/, + ) + expect(migration).toContain("'legacy-synchronized'") + expect(migration).toMatch( + /UPDATE "installation_memberships"\s+SET "source" = 'legacy-synchronized'\s+WHERE "source" = 'synchronized';/, + ) + }) + it("rolls back webhook mutations when the required revocation audit fails", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 221e10ec..a69c0e87 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -227,7 +227,10 @@ async function saveBaseline( rawPermission: baseline.rawPermission, mappedPrototypeRole: baseline.prototypeRole, admissionEligible: baseline.admissionEligible, + status: baseline.admissionEligible ? "active" : "removed", + synchronizationStatus: baseline.admissionEligible ? "current" : "removed", lastConfirmedAt: baseline.confirmedAt, + removedAt: baseline.admissionEligible ? null : baseline.confirmedAt, }, update: { rawPermission: baseline.rawPermission, @@ -350,6 +353,55 @@ async function removeRepositorySource( await enforceSynchronizedMemberships(database, removedAt) } +async function suspendInstallationSources( + database: Prisma.TransactionClient, + installationId: string, + suspendedAt: Date, +): Promise { + const sources = await database.gitHubRepositorySource.findMany({ + where: { installationId, status: "active" }, + select: { id: true }, + }) + for (const { id } of sources) await lockRepository(database, id) + await database.gitHubRepositorySource.updateMany({ + where: { installationId, status: "active" }, + data: { + status: "suspended", + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "removed", + removedAt: suspendedAt, + }, + }) + await database.gitHubSynchronizedAccess.updateMany({ + where: { repositorySource: { installationId } }, + data: { status: "removed", synchronizationStatus: "removed", removedAt: suspendedAt }, + }) + await enforceSynchronizedMemberships(database, suspendedAt) +} + +async function resumeInstallationSources( + database: Prisma.TransactionClient, + installationId: string, + resumedAt: Date, +): Promise { + const sources = await database.gitHubRepositorySource.findMany({ + where: { installationId, status: "suspended" }, + select: { id: true }, + }) + for (const { id } of sources) await lockRepository(database, id) + await database.gitHubRepositorySource.updateMany({ + where: { installationId, status: "suspended" }, + data: { + status: "active", + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + synchronizationError: null, + removedAt: null, + dirtyAt: resumedAt, + }, + }) +} + export function createGitHubAuthorizationService({ database, environment, @@ -369,6 +421,20 @@ export function createGitHubAuthorizationService({ return credentials(store, createGitHubIntegrationCredentialProvider(environment)) } + async function claimAdmissionGeneration(repositorySourceId: string) { + return store.lock(async (_records, transaction) => { + await lockRepository(transaction, repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active" || !source.admissionEnabled) return null + return transaction.gitHubRepositorySource.update({ + where: { id: repositorySourceId }, + data: { synchronizationGeneration: { increment: 1 } }, + }) + }) + } + async function reconcile(repositorySourceId: string, actorSessionToken?: string) { const source = await database.$transaction(async (transaction) => { await lockRepository(transaction, repositorySourceId) @@ -645,9 +711,25 @@ export function createGitHubAuthorizationService({ await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), ) } catch (error) { - if (error instanceof GitHubApiError && error.status === 404) continue + if (error instanceof GitHubApiError && error.status === 404) { + await store.lock(async (_records, transaction) => { + await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + current?.status === "active" && + current.synchronizationGeneration === source.synchronizationGeneration + ) { + await removeRepositorySource(transaction, source.id, new Date()) + } + }) + continue + } throw error } + const claimedSource = await claimAdmissionGeneration(source.id) + if (!claimedSource) continue const rawPermission = await permissionFor( token, profile.username, @@ -658,7 +740,7 @@ export function createGitHubAuthorizationService({ if (mapped.admissionEligible) { return { repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, + sourceGeneration: claimedSource.synchronizationGeneration, rawPermission, ...mapped, confirmedAt: new Date(), @@ -668,7 +750,7 @@ export function createGitHubAuthorizationService({ await store.lock((_records, transaction) => saveBaseline(transaction, identity.userId, { repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, + sourceGeneration: claimedSource.synchronizationGeneration, rawPermission, ...mapped, confirmedAt: new Date(), @@ -790,11 +872,29 @@ export function createGitHubAuthorizationService({ ) } await currentAdministrator(store, sessionToken) - const integration = await sourceCredentials() - const token = await createInstallationToken(integration) - const repository = repositorySchema.parse( - await requestGitHubApi(`/repositories/${parsed.data.githubRepositoryId}`, token), - ) + let integration: GitHubCredentials + let repository: z.infer + try { + integration = await sourceCredentials() + const token = await createInstallationToken(integration) + repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${parsed.data.githubRepositoryId}`, token), + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) { + throw new AuthenticationError( + 422, + "repository_source_inaccessible", + "The GitHub repository is not accessible to this installation.", + ) + } + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } return store.lock(async (_records, transaction) => { const actor = await currentAdministrator(store, sessionToken, transaction) const linked = await transaction.gitHubRepositorySource.upsert({ @@ -955,9 +1055,11 @@ export function createGitHubAuthorizationService({ ) } const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}` + const signatureBuffer = Buffer.from(signature) + const expectedBuffer = Buffer.from(expected) const valid = - signature.length === expected.length && - timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + signatureBuffer.length === expectedBuffer.length && + timingSafeEqual(signatureBuffer, expectedBuffer) if (!valid) { throw new AuthenticationError( 401, @@ -972,6 +1074,15 @@ export function createGitHubAuthorizationService({ "The GitHub webhook is invalid.", ) } + const relevant = new Set([ + "installation", + "installation_repositories", + "repository", + "member", + "membership", + "team_add", + ]) + if (!relevant.has(event)) return "ignored" let decoded: unknown try { decoded = JSON.parse(body.toString("utf8")) @@ -992,15 +1103,6 @@ export function createGitHubAuthorizationService({ } const integration = await sourceCredentials() if (String(payload.data.installation.id) !== integration.installationId) return "ignored" - const relevant = new Set([ - "installation", - "installation_repositories", - "repository", - "member", - "membership", - "team_add", - ]) - if (!relevant.has(event)) return "ignored" try { return await store.lock(async (_records, transaction) => { const repositoryId = payload.data.repository?.id @@ -1019,10 +1121,7 @@ export function createGitHubAuthorizationService({ }, }) const now = new Date() - if ( - event === "installation" && - ["deleted", "suspend"].includes(payload.data.action ?? "") - ) { + if (event === "installation" && payload.data.action === "deleted") { const sources = await transaction.gitHubRepositorySource.findMany({ where: { installationId: integration.installationId, status: "active" }, select: { id: true }, @@ -1030,6 +1129,10 @@ export function createGitHubAuthorizationService({ for (const source of sources) { await removeRepositorySource(transaction, source.id, now) } + } else if (event === "installation" && payload.data.action === "suspend") { + await suspendInstallationSources(transaction, integration.installationId, now) + } else if (event === "installation" && payload.data.action === "unsuspend") { + await resumeInstallationSources(transaction, integration.installationId, now) } else { const dirtyIds = new Set() const removedIds = new Set() diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 3bf4b8ae..579dc150 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -393,6 +393,7 @@ async function readAccessibleWorkspacePage({ while (items.length <= limit) { const candidates = await database.workspace.findMany({ where: { + ...(scanCursor ? { id: { gt: scanCursor } } : {}), OR: [ { grants: { some: { userId } } }, { prototypes: { some: { grants: { some: { userId } } } } }, @@ -401,7 +402,6 @@ async function readAccessibleWorkspacePage({ }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, - ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), take: 100, }) if (candidates.length === 0) break From 9a7ea50579f3185a34f61df522161f585b0aa074 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 20:52:12 -0700 Subject: [PATCH 24/56] fix(hub): isolate GitHub admission attempts Require collaborator membership for public sources, separate per-user admission ordering from reconciliation generations, preserve outage fallback, and cover policy mutation APIs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/database.md | 1 + hub/docs/github-integration.md | 4 +- hub/schema/github-admission-attempt.prisma | 19 + hub/schema/github-repository-source.prisma | 2 + .../migration.sql | 19 + .../create-service/index.db.test.ts | 362 +++++++++++++++--- .../create-service/index.ts | 76 +++- .../create-service/types.ts | 3 + .../create-service/index.db.test.ts | 123 ++++++ .../check-repository-admission/constants.ts | 1 + .../github/request-github-api/index.ts | 1 + hub/server/routes/product/index.test.ts | 38 ++ 12 files changed, 584 insertions(+), 65 deletions(-) create mode 100644 hub/schema/github-admission-attempt.prisma diff --git a/hub/docs/database.md b/hub/docs/database.md index 2817b95e..6f8b7897 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -51,6 +51,7 @@ Workspace and prototype access use separate grant tables so each user has at mos GitHub synchronization uses separate records: - `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, synchronization generation, and timestamps do not define identity. The generation is a compare-and-set boundary for rejecting stale in-flight reconciliation results. +- `GitHubAdmissionAttempt` stores a per-repository, per-GitHub-subject generation. Live sign-ins use it to order one user's permission confirmations without invalidating reconciliation or another user's sign-in. - `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. - `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. - `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index a4f1cc73..da36675c 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -4,7 +4,7 @@ GitHub-synchronized authorization uses repository permissions as an opt-in basel ## Source identity and credentials -A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, active or removed state, admission switch, synchronization status, and timestamps. Admission resolves the current owner and name from that immutable ID before checking a user permission, so a renamed repository cannot be confused with a replacement at its old name. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. +A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, visibility, active, suspended, or removed state, admission switch, synchronization status, and timestamps. Admission resolves current metadata from that immutable ID before checking a user permission, so a renamed repository cannot be confused with a replacement at its old name. Public and internal sources additionally require GitHub to confirm that the user is an actual collaborator before their permission can supply admission or a prototype baseline. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. All permission checks use an installation token created from the configured GitHub source-integration credentials. GitHub user authorization proves identity only. Hub does not retain or reuse the user's transient token for synchronization. @@ -31,7 +31,7 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Each live admission permission request also claims its generation before calling GitHub, so an older response cannot overwrite a newer positive or negative confirmation. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation or admission attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Live admission permission requests use a separate generation per repository and immutable GitHub subject, so an older response cannot overwrite that user's newer positive or negative confirmation without invalidating reconciliation or another user's sign-in. After GitHub replies, Hub locks and re-reads the relevant generation and applies results only when it is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation or same-user admission attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. `POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. diff --git a/hub/schema/github-admission-attempt.prisma b/hub/schema/github-admission-attempt.prisma new file mode 100644 index 00000000..0c2603d0 --- /dev/null +++ b/hub/schema/github-admission-attempt.prisma @@ -0,0 +1,19 @@ +model GitHubAdmissionAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String @map("repository_source_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource @relation(fields: [repositorySourceId], references: [id], onDelete: Cascade) + + // Attribute columns + subject String + generation Int @default(0) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([repositorySourceId, subject]) + @@map("github_admission_attempts") +} diff --git a/hub/schema/github-repository-source.prisma b/hub/schema/github-repository-source.prisma index 6d64f4d0..7113a058 100644 --- a/hub/schema/github-repository-source.prisma +++ b/hub/schema/github-repository-source.prisma @@ -6,6 +6,7 @@ model GitHubRepositorySource { // Relations prototypes Prototype[] synchronizedAccess GitHubSynchronizedAccess[] + admissionAttempts GitHubAdmissionAttempt[] webhookDeliveries GitHubWebhookDelivery[] auditTargets SecurityAudit[] @@ -15,6 +16,7 @@ model GitHubRepositorySource { owner String name String defaultBranch String @map("default_branch") + visibility String @default("unknown") status String @default("active") admissionEnabled Boolean @default(false) @map("admission_enabled") synchronizationGeneration Int @default(0) @map("synchronization_generation") diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql index 06cb6f3c..47d1f6f1 100644 --- a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -41,6 +41,7 @@ CREATE TABLE "github_repository_sources" ( "owner" TEXT NOT NULL, "name" TEXT NOT NULL, "default_branch" TEXT NOT NULL, + "visibility" TEXT NOT NULL DEFAULT 'unknown', "status" TEXT NOT NULL DEFAULT 'active', "admission_enabled" BOOLEAN NOT NULL DEFAULT false, "synchronization_generation" INTEGER NOT NULL DEFAULT 0, @@ -62,9 +63,25 @@ CREATE TABLE "github_repository_sources" ( ), CONSTRAINT "github_repository_sources_synchronization_status" CHECK ( "synchronization_status" IN ('dirty', 'current', 'failed', 'removed') + ), + CONSTRAINT "github_repository_sources_visibility" CHECK ( + "visibility" IN ('internal', 'private', 'public', 'unknown') ) ); +-- CreateTable +CREATE TABLE "github_admission_attempts" ( + "id" UUID NOT NULL, + "repository_source_id" UUID NOT NULL, + "subject" TEXT NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_admission_attempts_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_admission_attempts_generation" CHECK ("generation" >= 0) +); + -- CreateTable CREATE TABLE "github_synchronized_access" ( "id" UUID NOT NULL, @@ -118,6 +135,7 @@ ADD COLUMN "github_repository_source_id" UUID; -- CreateIndex CREATE UNIQUE INDEX "github_repository_sources_github_repository_id_key" ON "github_repository_sources"("github_repository_id"); +CREATE UNIQUE INDEX "github_admission_attempts_repository_source_id_subject_key" ON "github_admission_attempts"("repository_source_id", "subject"); CREATE INDEX "github_repository_sources_installation_id_status_idx" ON "github_repository_sources"("installation_id", "status"); CREATE UNIQUE INDEX "github_synchronized_access_repository_source_id_user_id_key" ON "github_synchronized_access"("repository_source_id", "user_id"); CREATE INDEX "github_synchronized_access_user_id_status_idx" ON "github_synchronized_access"("user_id", "status"); @@ -128,6 +146,7 @@ CREATE INDEX "security_audits_github_repository_source_id_idx" ON "security_audi -- AddForeignKey ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "github_admission_attempts" ADD CONSTRAINT "github_admission_attempts_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "github_webhook_deliveries" ADD CONSTRAINT "github_webhook_deliveries_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_source_repository_id_fkey" FOREIGN KEY ("source_repository_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 989036a1..910fe929 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -5,9 +5,15 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createAdministrationService } from "#server/domains/administration/create-service" import { createAuthenticationService } from "#server/domains/authentication/create-service" import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" +import type { + GitHubAdmissionBaseline, + GitHubAuthorizationService, +} from "#server/domains/github-authorization/create-service/types" import { createIdentityStore } from "#server/domains/identity/create-store" import { digestSecret } from "#server/utils/digest-secret" +import type { PrismaClient } from "../../../generated/prisma/client.ts" + type IdentityStore = ReturnType function environment() { @@ -126,6 +132,31 @@ function requestUrl(input: string | URL | Request): string { return typeof input === "string" ? input : input instanceof URL ? input.href : input.url } +async function saveAdmissionFixture( + prisma: PrismaClient, + service: GitHubAuthorizationService, + userId: string, + subject: string, + baseline: Omit, +) { + const attempt = await prisma.gitHubAdmissionAttempt.upsert({ + where: { + repositorySourceId_subject: { + repositorySourceId: baseline.repositorySourceId, + subject, + }, + }, + create: { repositorySourceId: baseline.repositorySourceId, subject, generation: 1 }, + update: { generation: { increment: 1 } }, + }) + await service.saveAdmissionBaseline(prisma, userId, { + ...baseline, + subject, + admissionGeneration: attempt.generation, + persist: true, + }) +} + describe("createGitHubAuthorizationService() database behavior", () => { it("uses only a fresh baseline for the linked repository and keeps repositories isolated", async () => { const { prisma, service, store } = await database() @@ -193,7 +224,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }), ]) const confirmedAt = new Date("2026-10-27T20:00:00.000Z") - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "write", @@ -267,7 +298,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -355,6 +386,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "new-name", owner: { login: "new-owner" }, default_branch: "trunk", + visibility: "private", }, { user: { id: 42 }, permission: "none" }, ] @@ -425,7 +457,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -477,6 +509,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "resurrected-name", owner: { login: "resurrected-owner" }, default_branch: "trunk", + visibility: "private", }), { status: 200 }, ), @@ -560,6 +593,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -606,7 +640,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }), ]) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: sourceA.id, sourceGeneration: sourceA.synchronizationGeneration, rawPermission: "write", @@ -632,6 +666,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository-a", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -709,7 +744,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) - it("uses a fresh cached grant only when that same repository's live check fails", async () => { + it("uses a fresh cached grant after metadata succeeds without persisting the fallback", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) const source = await prisma.gitHubRepositorySource.create({ @@ -724,7 +759,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }) const confirmedAt = new Date() - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -734,30 +769,66 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) vi.stubGlobal( "fetch", - vi.fn((input: string | URL | Request) => - Promise.resolve( - requestUrl(input).endsWith("/access_tokens") - ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) - : new Response(null, { status: 503 }), - ), - ), + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), ) - await expect( - service.evaluateAdmission({ - subject: actor.identity.subject, - username: actor.identity.username!, - displayName: actor.user.displayName, - avatarUrl: null, - }), - ).resolves.toEqual({ + const admission = await service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + expect(admission).toEqual({ repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 0, + persist: false, rawPermission: "read", admissionEligible: true, prototypeRole: "viewer", confirmedAt, }) + await service.saveAdmissionBaseline(prisma, actor.user.id, admission!) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + lastConfirmedAt: confirmedAt, + }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ synchronizationGeneration: source.synchronizationGeneration }) }) it.each([ @@ -782,7 +853,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -864,7 +935,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }) const confirmedAt = new Date("2026-10-28T18:00:00.000Z") - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "write", @@ -881,6 +952,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 1, + persist: true, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -894,6 +968,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, sourceGeneration: currentSource.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 1, + persist: true, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -964,6 +1041,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "renamed-repository", owner: { login: "renamed-owner" }, default_branch: "trunk", + visibility: "private", }), { status: 200 }, ), @@ -996,7 +1074,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { }), ).resolves.toMatchObject({ repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration + 1, + sourceGeneration: source.synchronizationGeneration, + subject: "42", + admissionGeneration: 1, + persist: true, rawPermission, prototypeRole, admissionEligible: true, @@ -1009,6 +1090,95 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, ) + it.each([ + { visibility: "public", collaboratorStatus: 204, admitted: true }, + { visibility: "public", collaboratorStatus: 404, admitted: false }, + { visibility: "internal", collaboratorStatus: 204, admitted: true }, + { visibility: "internal", collaboratorStatus: 404, admitted: false }, + ] as const)( + "$visibility repository admission is $admitted when collaborator membership returns $collaboratorStatus", + async ({ visibility, collaboratorStatus, admitted }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + visibility, + admissionEnabled: true, + }, + }) + const request = vi.fn((input: string | URL | Request): Promise => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility, + }), + { status: 200 }, + ), + ) + } + if (url.endsWith("/repos/owner/repository/collaborators/octocat")) { + return Promise.resolve(new Response(null, { status: collaboratorStatus })) + } + if (url.endsWith("/repos/owner/repository/collaborators/octocat/permission")) { + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { + status: 200, + }), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + const admission = await service.evaluateAdmission({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + if (admitted) { + expect(admission).toMatchObject({ + repositorySourceId: source.id, + subject: "42", + admissionGeneration: 1, + persist: true, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + }) + } else { + expect(admission).toBeNull() + } + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/owner/repository/collaborators/octocat", + ...(admitted + ? ["https://api.github.com/repos/owner/repository/collaborators/octocat/permission"] + : []), + ]) + }, + ) + it("invalidates a cached grant and session after an immutable repository 404", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { @@ -1032,7 +1202,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "write", @@ -1093,6 +1263,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -1245,6 +1416,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "new-name", owner: { login: "new-owner" }, default_branch: "trunk", + visibility: "private", }), { status: 200 }, ), @@ -1323,14 +1495,20 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, synchronized.user.id, { - repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, - rawPermission: "read", - prototypeRole: "viewer", - admissionEligible: true, - confirmedAt: new Date(), - }) + await saveAdmissionFixture( + prisma, + service, + synchronized.user.id, + synchronized.identity.subject, + { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }, + ) await expect( service.processWebhook( @@ -1343,6 +1521,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }, }, "repository", @@ -1406,6 +1585,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -1531,6 +1711,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -1629,7 +1810,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, target.user.id, { + await saveAdmissionFixture(prisma, service, target.user.id, target.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -1657,6 +1838,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -1714,7 +1896,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, lost.user.id, { + await saveAdmissionFixture(prisma, service, lost.user.id, lost.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -1724,6 +1906,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) const targetPermission = deferred() const targetPermissionRequested = deferred() + let targetPermissionRequests = 0 vi.stubGlobal( "fetch", vi.fn((input: string | URL | Request) => { @@ -1742,14 +1925,23 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), ) } if (url.includes("user-42")) { - targetPermissionRequested.resolve() - return targetPermission.promise + targetPermissionRequests += 1 + if (targetPermissionRequests === 1) { + targetPermissionRequested.resolve() + return targetPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { + status: 200, + }), + ) } return Promise.resolve( new Response(JSON.stringify({ user: { id: 43 }, permission: "none" }), { status: 200 }), @@ -1759,9 +1951,6 @@ describe("createGitHubAuthorizationService() database behavior", () => { const reconciliation = service.reconcileDue() await targetPermissionRequested.promise - const currentSource = await prisma.gitHubRepositorySource.findUniqueOrThrow({ - where: { id: source.id }, - }) const now = new Date() const provider = await store.read.currentAuthenticationProviderRevision("github") await store.lock((records) => @@ -1776,14 +1965,6 @@ describe("createGitHubAuthorizationService() database behavior", () => { expiresAt: new Date(now.getTime() + 60_000), }), ) - const freshBaseline = { - repositorySourceId: source.id, - sourceGeneration: currentSource.synchronizationGeneration, - rawPermission: "write" as const, - prototypeRole: "editor" as const, - admissionEligible: true, - confirmedAt: new Date(now.getTime() + 60_000), - } const authentication = createAuthenticationService({ store, github: { @@ -1804,11 +1985,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { providerRevisionId: provider!.id, admissionPolicy: { name: "github-synchronized", - admit: vi.fn().mockResolvedValue(freshBaseline), + admit: (profile) => service.evaluateAdmission(profile), saveBaseline: (database, userId, baseline) => service.saveAdmissionBaseline(database, userId, baseline), }, - now: () => now, }) await expect(authentication.complete("code", "state", "browser-token")).resolves.toMatchObject({ @@ -1819,6 +1999,12 @@ describe("createGitHubAuthorizationService() database behavior", () => { ) await reconciliation + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "current", + }) expect( await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), ).toMatchObject({ source: "synchronized", status: "active" }) @@ -1834,7 +2020,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ rawPermission: "write", admissionEligible: true, - lastConfirmedAt: freshBaseline.confirmedAt, + status: "active", }) expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(1) expect( @@ -1903,7 +2089,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { synchronizationStatus: "current", }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -1979,6 +2165,76 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.count({ where: { userId: actor.user.id } })).toBe(0) }) + it("unlinks a suspended source and clears its admission policy and baselines", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await service.processWebhook( + webhook( + { action: "suspend", installation: { id: 7 } }, + "installation", + "installation-suspend-before-unlink", + ), + ) + + await expect( + service.unlinkRepository("administrator-token", source.id), + ).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + status: "removed", + synchronizationStatus: "removed", + }) + expect( + await prisma.securityAudit.findFirst({ + where: { event: "github.repository.unlink", githubRepositorySourceId: source.id }, + }), + ).toMatchObject({ actorUserId: administrator.user.id }) + }) + it("keeps the synchronized authorization migration constraints and compatibility backfill", () => { const migration = readFileSync( new URL( @@ -2015,7 +2271,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index a69c0e87..a1f1c2e5 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -123,8 +123,20 @@ async function permissionFor( username: string, subject: string, fullName: string, + visibility: string, ): Promise { try { + if (visibility !== "private") { + try { + await requestGitHubApi( + `/repos/${fullName}/collaborators/${encodeURIComponent(username)}`, + token, + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return "none" + throw error + } + } const response = collaboratorPermissionSchema.parse( await requestGitHubApi( `/repos/${fullName}/collaborators/${encodeURIComponent(username)}/permission`, @@ -175,7 +187,9 @@ async function saveBaseline( baseline: GitHubAdmissionBaseline, requireAdmissionEnabled = false, skipOlderConfirmation = false, + requireAdmissionAttempt = true, ): Promise<"saved" | "superseded"> { + if (!baseline.persist) return "saved" await lockRepository(database, baseline.repositorySourceId) const source = await database.gitHubRepositorySource.findUnique({ where: { id: baseline.repositorySourceId }, @@ -197,6 +211,24 @@ async function saveBaseline( "GitHub authorization changed. Start again.", ) } + if (requireAdmissionAttempt) { + const attempt = await database.gitHubAdmissionAttempt.findUnique({ + where: { + repositorySourceId_subject: { + repositorySourceId: baseline.repositorySourceId, + subject: baseline.subject, + }, + }, + select: { generation: true }, + }) + if (!attempt || attempt.generation !== baseline.admissionGeneration) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + } const existing = await database.gitHubSynchronizedAccess.findUnique({ where: { repositorySourceId_userId: { @@ -421,17 +453,19 @@ export function createGitHubAuthorizationService({ return credentials(store, createGitHubIntegrationCredentialProvider(environment)) } - async function claimAdmissionGeneration(repositorySourceId: string) { + async function claimAdmissionGeneration(repositorySourceId: string, subject: string) { return store.lock(async (_records, transaction) => { await lockRepository(transaction, repositorySourceId) const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) if (!source || source.status !== "active" || !source.admissionEnabled) return null - return transaction.gitHubRepositorySource.update({ - where: { id: repositorySourceId }, - data: { synchronizationGeneration: { increment: 1 } }, + const attempt = await transaction.gitHubAdmissionAttempt.upsert({ + where: { repositorySourceId_subject: { repositorySourceId, subject } }, + create: { repositorySourceId, subject, generation: 1 }, + update: { generation: { increment: 1 } }, }) + return { source, admissionGeneration: attempt.generation } }) } @@ -497,9 +531,11 @@ export function createGitHubAuthorizationService({ identity.username!, identity.subject, repository.full_name, + repository.visibility, ) return { userId: identity.userId, + subject: identity.subject, rawPermission, ...mapGitHubPermission(rawPermission), } @@ -525,6 +561,7 @@ export function createGitHubAuthorizationService({ owner: repository.owner.login, name: repository.name, defaultBranch: repository.default_branch, + visibility: repository.visibility, status: "active", removedAt: null, synchronizationStatus: "current", @@ -540,6 +577,9 @@ export function createGitHubAuthorizationService({ { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: result.subject, + admissionGeneration: 0, + persist: true, rawPermission: result.rawPermission, admissionEligible: result.admissionEligible, prototypeRole: result.prototypeRole, @@ -547,6 +587,7 @@ export function createGitHubAuthorizationService({ }, false, true, + false, ) } await enforceSynchronizedMemberships(transaction, confirmedAt) @@ -680,6 +721,9 @@ export function createGitHubAuthorizationService({ return { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: 0, + persist: false, rawPermission: baseline.rawPermission as GitHubRepositoryPermission, ...mapped, confirmedAt: baseline.lastConfirmedAt, @@ -728,19 +772,23 @@ export function createGitHubAuthorizationService({ } throw error } - const claimedSource = await claimAdmissionGeneration(source.id) - if (!claimedSource) continue + const claim = await claimAdmissionGeneration(source.id, profile.subject) + if (!claim) continue const rawPermission = await permissionFor( token, profile.username, profile.subject, repository.full_name, + repository.visibility, ) const mapped = mapGitHubPermission(rawPermission) if (mapped.admissionEligible) { return { repositorySourceId: source.id, - sourceGeneration: claimedSource.synchronizationGeneration, + sourceGeneration: claim.source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: claim.admissionGeneration, + persist: true, rawPermission, ...mapped, confirmedAt: new Date(), @@ -750,7 +798,10 @@ export function createGitHubAuthorizationService({ await store.lock((_records, transaction) => saveBaseline(transaction, identity.userId, { repositorySourceId: source.id, - sourceGeneration: claimedSource.synchronizationGeneration, + sourceGeneration: claim.source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: claim.admissionGeneration, + persist: true, rawPermission, ...mapped, confirmedAt: new Date(), @@ -766,6 +817,9 @@ export function createGitHubAuthorizationService({ return { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: 0, + persist: false, rawPermission: baseline.rawPermission as GitHubRepositoryPermission, ...mapped, confirmedAt: baseline.lastConfirmedAt, @@ -906,6 +960,7 @@ export function createGitHubAuthorizationService({ owner: repository.owner.login, name: repository.name, defaultBranch: repository.default_branch, + visibility: repository.visibility, admissionEnabled: parsed.data.admissionEnabled ?? false, dirtyAt: new Date(), }, @@ -915,6 +970,7 @@ export function createGitHubAuthorizationService({ owner: repository.owner.login, name: repository.name, defaultBranch: repository.default_branch, + visibility: repository.visibility, status: "active", removedAt: null, ...(parsed.data.admissionEnabled === undefined @@ -952,7 +1008,7 @@ export function createGitHubAuthorizationService({ const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) - if (!source || source.status !== "active") { + if (!source || !["active", "suspended"].includes(source.status)) { throw new AuthenticationError( 404, "repository_source_not_found", @@ -992,7 +1048,7 @@ export function createGitHubAuthorizationService({ const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) - if (!source || source.status !== "active") { + if (!source || !["active", "suspended"].includes(source.status)) { throw new AuthenticationError( 404, "repository_source_not_found", diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts index faf87aad..66328656 100644 --- a/hub/server/domains/github-authorization/create-service/types.ts +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -5,6 +5,9 @@ import type { GitHubRepositoryPermission, MappedGitHubPermission } from "../map- export interface GitHubAdmissionBaseline extends MappedGitHubPermission { repositorySourceId: string sourceGeneration: number + subject: string + admissionGeneration: number + persist: boolean rawPermission: GitHubRepositoryPermission confirmedAt: Date } diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index b5b9fa75..33eb1d3f 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -226,6 +226,129 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(await prisma.securityAudit.count()).toBe(0) }) + it("enforces fresh prototype administration and saves authorization mode changes with audits", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "authorization-administrator", + token: "authorization-administrator-token", + role: "administrator", + }) + const workspace = (await service.createWorkspace("authorization-administrator-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype( + "authorization-administrator-token", + workspace.id, + { name: "Prototype" }, + )) as { id: string } + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + status: "suspended", + }, + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date() }, + }) + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).rejects.toMatchObject({ + status: 422, + code: "repository_source_unavailable", + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }, + ) + expect( + await prisma.securityAudit.count({ + where: { event: "prototype.authorization.policy", prototypeId: prototype.id }, + }), + ).toBe(0) + + await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { status: "active", removedAt: null }, + }) + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).resolves.toMatchObject({ + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }, + ) + + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "hub-managed", + }), + ).resolves.toMatchObject({ + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }, + ) + expect( + await prisma.securityAudit.findMany({ + where: { event: "prototype.authorization.policy", prototypeId: prototype.id }, + orderBy: { createdAt: "asc" }, + select: { + actorUserId: true, + githubRepositorySourceId: true, + context: true, + }, + }), + ).toEqual([ + { + actorUserId: actor.user.id, + githubRepositorySourceId: source.id, + context: { reason: "github_synchronized" }, + }, + { + actorUserId: actor.user.id, + githubRepositorySourceId: null, + context: { reason: "hub_managed" }, + }, + ]) + }) + it("rejects workspace creation by a member without writing product state", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts index 6d17ee27..d06b0a7a 100644 --- a/hub/server/integrations/github/check-repository-admission/constants.ts +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -24,4 +24,5 @@ export const repositorySchema = z.object({ name: z.string().min(1), owner: z.object({ login: z.string().min(1) }), default_branch: z.string().min(1), + visibility: z.enum(["internal", "private", "public"]), }) diff --git a/hub/server/integrations/github/request-github-api/index.ts b/hub/server/integrations/github/request-github-api/index.ts index 8d8eece3..b2a56334 100644 --- a/hub/server/integrations/github/request-github-api/index.ts +++ b/hub/server/integrations/github/request-github-api/index.ts @@ -21,5 +21,6 @@ export async function requestGitHubApi( throw new GitHubApiError(0) } if (!response.ok) throw new GitHubApiError(response.status) + if (response.status === 204) return null return response.json() } diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts index bc77fd73..40893e1a 100644 --- a/hub/server/routes/product/index.test.ts +++ b/hub/server/routes/product/index.test.ts @@ -38,6 +38,7 @@ function runtime( createPrototype: vi.fn(), getPrototype: vi.fn(), archivePrototype: vi.fn(), + setPrototypeAuthorization: vi.fn(), explainPrototypeAccess: vi.fn(), setPrototypeAccess: vi.fn(), removePrototypeAccess: vi.fn(), @@ -232,6 +233,15 @@ describe("product routes", () => { path: "/api/prototypes/prototype/archive", call: "archivePrototype", }, + { + method: "PUT", + path: "/api/prototypes/prototype/authorization", + call: "setPrototypeAuthorization", + body: { + mode: "github-synchronized", + repositorySourceId: "00000000-0000-0000-0000-000000000001", + }, + }, { method: "GET", path: "/api/prototypes/prototype/access/explanation", @@ -294,6 +304,34 @@ describe("product routes", () => { }, ) + it("forwards prototype authorization changes through the protected HTTP route", async () => { + const saved = { + id: "prototype", + authorizationMode: "github-synchronized", + sourceRepositoryId: "00000000-0000-0000-0000-000000000001", + } + const authentication = runtime({ + setPrototypeAuthorization: vi.fn().mockResolvedValue(saved), + }) + const baseUrl = await serve(authentication) + const body = { + mode: "github-synchronized", + repositorySourceId: saved.sourceRepositoryId, + } + + const response = await fetch(`${baseUrl}/api/prototypes/prototype/authorization`, { + method: "PUT", + headers: protectedHeaders, + body: JSON.stringify(body), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ prototype: saved }) + expect( + authentication.prototypeAuthorization.setPrototypeAuthorization.mock.calls, + ).toContainEqual([sessionToken, "prototype", body]) + }) + it.each([ { name: "missing origin", From 8cf7064f13a60dbc034a869fdd771fc6998f3afe Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 04:01:58 -0700 Subject: [PATCH 25/56] fix(hub): order synchronized permission updates Share per-user ordering across reconciliation and sign-in, preserve valid manual outcomes, batch removal enforcement, and audit synchronized administration preservation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 6 +- hub/docs/prototype-authorization.md | 2 +- hub/docs/testing-and-operations.md | 2 +- .../complete-github-authorization/index.ts | 12 +- .../authentication/create-runtime/index.ts | 2 + .../create-service/index.db.test.ts | 3 + .../create-service/index.test.ts | 145 +++++ .../create-service/index.db.test.ts | 545 ++++++++++++++++++ .../create-service/index.ts | 132 ++++- .../create-service/types.ts | 6 + .../create-service/index.db.test.ts | 113 ++++ .../create-service/index.ts | 130 +++-- .../create-service/types.ts | 5 + 13 files changed, 1033 insertions(+), 70 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index da36675c..54ed5c58 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -31,15 +31,15 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Live admission permission requests use a separate generation per repository and immutable GitHub subject, so an older response cannot overwrite that user's newer positive or negative confirmation without invalidating reconciliation or another user's sign-in. After GitHub replies, Hub locks and re-reads the relevant generation and applies results only when it is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation or same-user admission attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. Admission claims before resolving mutable repository metadata and rejects a source-generation change, so a rename or transfer cannot substitute a replacement repository. After GitHub replies, Hub locks and re-reads the relevant generations and applies results only when they are still current. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. ## Staleness and outages A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 removes that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. -Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index acad32eb..b1886589 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -50,7 +50,7 @@ The fixed mapping is: | `read` | `viewer` | | `none` or missing collaborator | No access | -GitHub never supplies Hub `administrator` authority. Manual workspace and prototype grants and denies live in separate tables and synchronization does not update or delete them. +GitHub never supplies Hub `administrator` authority. Manual workspace and prototype grants and denies live in separate tables and synchronization does not update or delete them. Converting a synchronized member to manual membership because a Hub administrator grants product administration writes a structured membership-preservation audit in the same transaction. Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index d724f97a..49450eb1 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation, identity mismatch, built-in role-name mapping, provider and credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and deletion events, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, source-generation changes during metadata resolution, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation shared by reconciliation and sign-in, identity mismatch, built-in role-name mapping, provider-response versus apply failures, credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and batched deletion events, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, long manual reconciliation attribution, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and audited administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index 29d828ee..fc20d365 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -307,7 +307,17 @@ export async function completeGitHubAuthorization( } } if (admissionBaseline && admissionBaseline !== "legacy") { - await admissionPolicy.saveBaseline?.(transaction, identity.userId, admissionBaseline) + try { + await admissionPolicy.saveBaseline?.(transaction, identity.userId, admissionBaseline) + } catch (error) { + if (!( + error instanceof AuthenticationError && + error.code === "github_authorization_changed" && + membership?.source !== "synchronized" + )) { + throw error + } + } } const sessionToken = randomBytes(32).toString("base64url") const session = await records.createSession({ diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index dcff7c00..8a1976ae 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -97,6 +97,8 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv githubAuthorization.prototypeBaseline(database, userId, prototypeId), workspaceIds: (database, userId) => githubAuthorization.prototypeWorkspaceIds(database, userId), + prototypeIds: (database, userId, workspaceId) => + githubAuthorization.prototypeIds(database, userId, workspaceId), }, database: prisma, store, diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 8e11196c..946bce3d 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -182,6 +182,9 @@ describe("createAuthenticationService() database behavior", () => { admit: vi.fn().mockResolvedValue({ repositorySourceId: "00000000-0000-0000-0000-000000000001", sourceGeneration: 3, + subject: graph.identity.subject, + admissionGeneration: 4, + persist: true, rawPermission: "read", admissionEligible: true, prototypeRole: "viewer", diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index a0184e0c..2533d311 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -308,6 +308,9 @@ describe("createAuthenticationService()", () => { admit: vi.fn().mockResolvedValue({ repositorySourceId: "00000000-0000-0000-0000-000000000001", sourceGeneration: 3, + subject: "42", + admissionGeneration: 4, + persist: true, rawPermission: "write", admissionEligible: true, prototypeRole: "editor", @@ -437,6 +440,148 @@ describe("createAuthenticationService()", () => { expect(saveBaseline).not.toHaveBeenCalled() }) + it.each(["manual", "invitation"] as const)( + "keeps an active %s member signed in when only the baseline save was superseded", + async (source) => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const membership = { + id: "membership", + userId: "user", + status: "active" as const, + role: "member" as const, + source, + suspendedAt: null, + removedAt: null, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + membership: vi.fn().mockResolvedValue(membership), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const saveBaseline = vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed.", + ), + ) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + subject: "42", + admissionGeneration: 4, + persist: true, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline, + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(saveBaseline).toHaveBeenCalledOnce() + expect(identityRecords.createSession).toHaveBeenCalledOnce() + }, + ) + + it("propagates a baseline save failure other than a superseded authorization", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "manual", + suspendedAt: null, + removedAt: null, + }), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + }) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + subject: "42", + admissionGeneration: 4, + persist: true, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline: vi + .fn() + .mockRejectedValue( + new AuthenticationError(500, "apply_failed", "The baseline could not be saved."), + ), + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 500, code: "apply_failed" }) + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + it("binds bootstrap enrollment to the configured one-use secret", async () => { const identityRecords = records() const service = createAuthenticationService({ diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 910fe929..6f94e904 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -542,6 +542,90 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ effect: "allow", role: "commenter" }) }) + it("rejects repository metadata observed before a source generation change", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const sourcesObserved = deferred() + const continueAdmission = deferred() + const findMany = prisma.gitHubRepositorySource.findMany.bind(prisma.gitHubRepositorySource) + vi.spyOn(prisma.gitHubRepositorySource, "findMany").mockImplementation(((args) => + findMany(args).then(async (sources) => { + sourcesObserved.resolve() + await continueAdmission.promise + return sources + })) as typeof prisma.gitHubRepositorySource.findMany) + const request = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + return Promise.resolve( + new Response( + JSON.stringify({ + id: 999, + full_name: "original-owner/original-name", + name: "original-name", + owner: { login: "replacement-owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + }) + vi.stubGlobal("fetch", request) + + const admission = service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + await sourcesObserved.promise + await service.processWebhook( + webhook( + { + action: "added", + installation: { id: 7 }, + repository: { + id: 100, + name: "renamed", + owner: { login: "new-owner" }, + default_branch: "main", + visibility: "private", + }, + }, + "member", + "source-generation-changed-before-claim", + ), + ) + continueAdmission.resolve() + + await expect(admission).resolves.toBeNull() + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + ]) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "dirty", + }) + }) + it("does not start another scheduled reconciliation while one is running", async () => { const { prisma, service, store } = await database() await createUser(store, { subject: "42" }) @@ -608,6 +692,94 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("applies a long manual reconciliation after freshness expires and attributes its audit", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "target-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const permissionResponse = deferred() + const permissionRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + permissionRequested.resolve() + return permissionResponse.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { status: 200 }), + ) + }), + ) + + const reconciliation = service.reconcileRepository("administrator-token", source.id) + await permissionRequested.promise + await prisma.session.update({ + where: { id: administrator.session!.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 6 * 60_000) }, + }) + permissionResponse.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + + await expect(reconciliation).resolves.toMatchObject({ + id: source.id, + synchronizationStatus: "current", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: target.session!.id } })).toBeNull() + expect( + await prisma.securityAudit.findFirst({ + where: { event: "github.repository.reconcile", githubRepositorySourceId: source.id }, + }), + ).toMatchObject({ + actorUserId: administrator.user.id, + sessionId: administrator.session!.id, + authenticationProviderRevisionId: administrator.session!.authenticationProviderRevisionId, + }) + }) + it("persists a live denial so a later outage cannot reuse the old positive cache", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { @@ -1354,6 +1526,124 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("records a malformed provider response separately from provider availability", async () => { + const { prisma, service } = await database() + await prisma.gitHubIntegrationState.create({ + data: { id: "00000000-0000-4000-8000-000000000001" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 100, full_name: "owner/repository" }), { + status: 200, + }), + ), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "provider_response_invalid", + }) + }) + + it("records an apply failure and propagates the original internal error", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_reconciliation_apply() RETURNS trigger AS $$ + BEGIN + IF NEW.synchronization_status = 'current' THEN + RAISE EXCEPTION 'synthetic reconciliation apply failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_reconciliation_apply + BEFORE UPDATE ON github_repository_sources + FOR EACH ROW EXECUTE FUNCTION reject_reconciliation_apply(); + `) + onTestFinished(async () => { + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS reject_reconciliation_apply ON github_repository_sources", + ) + await prisma.$executeRawUnsafe("DROP FUNCTION IF EXISTS reject_reconciliation_apply()") + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { status: 200 }), + ) + }), + ) + + await expect(service.reconcileRepository("administrator-token", source.id)).rejects.toThrow( + "synthetic reconciliation apply failure", + ) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "apply_failed", + }) + }) + it("lists repository sources for an administrator without fresh verification", async () => { const { prisma, service, store } = await database() const administrator = await createUser(store, { @@ -1787,6 +2077,132 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) }) + it("keeps a reconciliation denial authoritative when an earlier sign-in responds later", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const signInPermission = deferred() + const signInPermissionRequested = deferred() + let permissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequests += 1 + if (permissionRequests === 1) { + signInPermissionRequested.resolve() + return signInPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const signIn = authentication.complete("code", "state", "browser-token") + await signInPermissionRequested.promise + await service.reconcileDue() + signInPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + + await expect(signIn).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect(permissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + it("does not overwrite an administrator suspension while reconciliation is waiting", async () => { const { prisma, service, store } = await database() await createUser(store, { @@ -2070,6 +2486,135 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.gitHubWebhookDelivery.count()).toBe(0) }) + it.each([ + { + name: "installation deletion", + event: "installation", + payload: { action: "deleted", installation: { id: 7 } }, + }, + { + name: "repository removal", + event: "installation_repositories", + payload: { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + }, + ])("removes suspended sources after $name", async ({ event, payload }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + status: "suspended", + synchronizationStatus: "removed", + }, + }) + + await expect( + service.processWebhook(webhook(payload, event, `suspended-${event}-removal`)), + ).resolves.toBe("accepted") + await expect( + service.processWebhook( + webhook( + { action: "unsuspend", installation: { id: 7 } }, + "installation", + `suspended-${event}-later-unsuspend`, + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + }) + + it("enforces synchronized memberships once after removing multiple sources", async () => { + const { prisma, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const sources = await Promise.all( + ["100", "200"].map((githubRepositoryId) => + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId, + appId: "12345", + installationId: "7", + owner: "owner", + name: `repository-${githubRepositoryId}`, + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ), + ) + let membershipEnforcementQueries = 0 + const observedDatabase = prisma.$extends({ + query: { + installationMembership: { + findMany({ args, query }) { + if (args.where?.source === "synchronized" && args.where.status === "active") { + membershipEnforcementQueries += 1 + } + return query(args) + }, + }, + }, + }) + const observedStore = createIdentityStore(observedDatabase as unknown as PrismaClient) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase as unknown as PrismaClient, + environment: environment(), + store: observedStore, + }) + for (const source of sources) { + await saveAdmissionFixture(prisma, observedService, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + } + + await expect( + observedService.processWebhook( + webhook( + { action: "deleted", installation: { id: 7 } }, + "installation", + "multi-source-installation-delete", + ), + ), + ).resolves.toBe("accepted") + + expect(membershipEnforcementQueries).toBe(1) + expect( + await prisma.gitHubRepositorySource.findMany({ + where: { id: { in: sources.map(({ id }) => id) } }, + select: { status: true }, + }), + ).toEqual([{ status: "removed" }, { status: "removed" }]) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }) + it("suspends linked sources without losing policy and resumes them dirty without baselines", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index a1f1c2e5..35e11e8a 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -222,6 +222,7 @@ async function saveBaseline( select: { generation: true }, }) if (!attempt || attempt.generation !== baseline.admissionGeneration) { + if (skipOlderConfirmation) return "superseded" throw new AuthenticationError( 409, "github_authorization_changed", @@ -366,6 +367,7 @@ async function removeRepositorySource( database: Prisma.TransactionClient, repositorySourceId: string, removedAt: Date, + enforceMemberships = true, ): Promise { await lockRepository(database, repositorySourceId) await database.gitHubRepositorySource.update({ @@ -382,7 +384,7 @@ async function removeRepositorySource( where: { repositorySourceId }, data: { status: "removed", synchronizationStatus: "removed", removedAt }, }) - await enforceSynchronizedMemberships(database, removedAt) + if (enforceMemberships) await enforceSynchronizedMemberships(database, removedAt) } async function suspendInstallationSources( @@ -453,13 +455,25 @@ export function createGitHubAuthorizationService({ return credentials(store, createGitHubIntegrationCredentialProvider(environment)) } - async function claimAdmissionGeneration(repositorySourceId: string, subject: string) { + async function claimAdmissionGeneration( + repositorySourceId: string, + subject: string, + observedSourceGeneration: number, + requireAdmissionEnabled: boolean, + ) { return store.lock(async (_records, transaction) => { await lockRepository(transaction, repositorySourceId) const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) - if (!source || source.status !== "active" || !source.admissionEnabled) return null + if ( + !source || + source.status !== "active" || + source.synchronizationGeneration !== observedSourceGeneration || + (requireAdmissionEnabled && !source.admissionEnabled) + ) { + return null + } const attempt = await transaction.gitHubAdmissionAttempt.upsert({ where: { repositorySourceId_subject: { repositorySourceId, subject } }, create: { repositorySourceId, subject, generation: 1 }, @@ -494,6 +508,7 @@ export function createGitHubAuthorizationService({ }, }) }) + let phase: "credentials" | "token" | "repository" | "permissions" | "apply" = "credentials" try { const integration = await sourceCredentials() if ( @@ -506,10 +521,13 @@ export function createGitHubAuthorizationService({ "The GitHub source integration configuration changed. Relink the repository.", ) } + phase = "token" const token = await createInstallationToken(integration) + phase = "repository" const repository = repositorySchema.parse( await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), ) + phase = "permissions" const identities = [] let identityCursor: string | undefined while (true) { @@ -526,6 +544,13 @@ export function createGitHubAuthorizationService({ } const confirmedAt = new Date() const results = await mapWithConcurrency(identities, 10, async (identity) => { + const claim = await claimAdmissionGeneration( + source.id, + identity.subject, + source.synchronizationGeneration, + false, + ) + if (!claim) return null const rawPermission = await permissionFor( token, identity.username!, @@ -536,10 +561,12 @@ export function createGitHubAuthorizationService({ return { userId: identity.userId, subject: identity.subject, + admissionGeneration: claim.admissionGeneration, rawPermission, ...mapGitHubPermission(rawPermission), } }) + phase = "apply" const applied = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ @@ -553,7 +580,7 @@ export function createGitHubAuthorizationService({ return false } const actor = actorSessionToken - ? await currentAdministrator(store, actorSessionToken, transaction) + ? await currentAdministrator(store, actorSessionToken, transaction, false) : null await transaction.gitHubRepositorySource.update({ where: { id: source.id }, @@ -571,6 +598,7 @@ export function createGitHubAuthorizationService({ }, }) for (const result of results) { + if (!result) continue await saveBaseline( transaction, result.userId, @@ -578,7 +606,7 @@ export function createGitHubAuthorizationService({ repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, subject: result.subject, - admissionGeneration: 0, + admissionGeneration: result.admissionGeneration, persist: true, rawPermission: result.rawPermission, admissionEligible: result.admissionEligible, @@ -587,7 +615,7 @@ export function createGitHubAuthorizationService({ }, false, true, - false, + true, ) } await enforceSynchronizedMemberships(transaction, confirmedAt) @@ -638,25 +666,36 @@ export function createGitHubAuthorizationService({ } return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) } - const failed = await database.gitHubRepositorySource.updateMany({ - where: { - id: source.id, - status: "active", - synchronizationGeneration: source.synchronizationGeneration, - }, - data: { - synchronizationStatus: "failed", - synchronizationError: - error instanceof GitHubApiError - ? `github_${error.status}` - : error instanceof AuthenticationError - ? error.code - : "provider_unavailable", - }, - }) + const synchronizationError = + phase === "apply" + ? "apply_failed" + : error instanceof GitHubApiError + ? `github_${error.status}` + : error instanceof AuthenticationError + ? error.code + : ["token", "repository", "permissions"].includes(phase) + ? "provider_response_invalid" + : "provider_unavailable" + let failed + try { + failed = await database.gitHubRepositorySource.updateMany({ + where: { + id: source.id, + status: "active", + synchronizationGeneration: source.synchronizationGeneration, + }, + data: { + synchronizationStatus: "failed", + synchronizationError, + }, + }) + } catch { + throw error + } if (failed.count === 0) { return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) } + if (phase === "apply") throw error if (error instanceof AuthenticationError) throw error throw new AuthenticationError( 503, @@ -749,6 +788,13 @@ export function createGitHubAuthorizationService({ continue } try { + const claim = await claimAdmissionGeneration( + source.id, + profile.subject, + source.synchronizationGeneration, + true, + ) + if (!claim) continue let repository: z.infer try { repository = repositorySchema.parse( @@ -772,8 +818,6 @@ export function createGitHubAuthorizationService({ } throw error } - const claim = await claimAdmissionGeneration(source.id, profile.subject) - if (!claim) continue const rawPermission = await permissionFor( token, profile.username, @@ -911,6 +955,30 @@ export function createGitHubAuthorizationService({ return [...new Set(prototypes.map(({ workspaceId }) => workspaceId))] }, + async prototypeIds(transaction, userId, workspaceId, now = new Date()) { + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const prototypes = await transaction.prototype.findMany({ + where: { + workspaceId, + authorizationMode: "github-synchronized", + grants: { none: { userId, effect: "deny" } }, + sourceRepository: { + status: "active", + synchronizedAccess: { + some: { + userId, + status: "active", + mappedPrototypeRole: { not: null }, + lastConfirmedAt: { gte: cutoff }, + }, + }, + }, + }, + select: { id: true }, + }) + return prototypes.map(({ id }) => id) + }, + async listRepositories(sessionToken) { await currentAdministrator(store, sessionToken, undefined, false) return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) @@ -1179,12 +1247,16 @@ export function createGitHubAuthorizationService({ const now = new Date() if (event === "installation" && payload.data.action === "deleted") { const sources = await transaction.gitHubRepositorySource.findMany({ - where: { installationId: integration.installationId, status: "active" }, + where: { + installationId: integration.installationId, + status: { in: ["active", "suspended"] }, + }, select: { id: true }, }) for (const source of sources) { - await removeRepositorySource(transaction, source.id, now) + await removeRepositorySource(transaction, source.id, now, false) } + await enforceSynchronizedMemberships(transaction, now) } else if (event === "installation" && payload.data.action === "suspend") { await suspendInstallationSources(transaction, integration.installationId, now) } else if (event === "installation" && payload.data.action === "unsuspend") { @@ -1202,12 +1274,16 @@ export function createGitHubAuthorizationService({ if (repositoryId) dirtyIds.add(String(repositoryId)) if (removedIds.size > 0) { const removedSources = await transaction.gitHubRepositorySource.findMany({ - where: { githubRepositoryId: { in: [...removedIds] }, status: "active" }, + where: { + githubRepositoryId: { in: [...removedIds] }, + status: { in: ["active", "suspended"] }, + }, select: { id: true }, }) for (const source of removedSources) { - await removeRepositorySource(transaction, source.id, now) + await removeRepositorySource(transaction, source.id, now, false) } + await enforceSynchronizedMemberships(transaction, now) } if (dirtyIds.size > 0) { await transaction.gitHubRepositorySource.updateMany({ diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts index 66328656..9c8c6ab1 100644 --- a/hub/server/domains/github-authorization/create-service/types.ts +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -39,6 +39,12 @@ export interface GitHubAuthorizationService { userId: string, now?: Date, ): Promise + prototypeIds( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + workspaceId: string, + now?: Date, + ): Promise listRepositories(sessionToken: string): Promise linkRepository(sessionToken: string, input: unknown): Promise updateRepository( diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 33eb1d3f..3d9b0b38 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -2054,6 +2054,29 @@ describe("createPrototypeAuthorizationService() database behavior", () => { where: { userId: prototypeTarget.user.id }, }), ).toMatchObject({ source: "manual", status: "active" }) + expect( + await prisma.securityAudit.findMany({ + where: { event: "membership.synchronized.preserve" }, + orderBy: { createdAt: "asc" }, + }), + ).toEqual([ + expect.objectContaining({ + actorUserId: actor.user.id, + sessionId: actor.session.id, + authenticationProviderRevisionId: actor.session.authenticationProviderRevisionId, + targetMembershipId: workspaceTarget.membership.id, + targetUserId: workspaceTarget.user.id, + context: { reason: "product_administration" }, + }), + expect.objectContaining({ + actorUserId: actor.user.id, + sessionId: actor.session.id, + authenticationProviderRevisionId: actor.session.authenticationProviderRevisionId, + targetMembershipId: prototypeTarget.membership.id, + targetUserId: prototypeTarget.user.id, + context: { reason: "product_administration" }, + }), + ]) }) it("passes the mutation transaction to a synchronized prototype baseline", async () => { @@ -2153,6 +2176,96 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ]) }) + it("uses batched baseline prototype IDs without per-prototype authorization queries", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const baselinePrototypeIds = vi.fn>() + const { prisma, service, store } = await database({ + baseline: baselineAccess, + prototypeIds: baselinePrototypeIds, + }) + const actor = await createActor(store, { + subject: "prototype-baseline-batch", + token: "prototype-baseline-batch-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const directId = "00000000-0000-4000-8000-000000000010" + const baselineId = "00000000-0000-4000-8000-000000000020" + await prisma.prototype.createMany({ + data: [ + { + id: directId, + name: "Direct", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + { + id: baselineId, + name: "Synchronized", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + ...Array.from({ length: 5 }, (_, index) => ({ + id: `00000000-0000-4000-8000-00000000010${index}`, + name: `Unrelated ${index}`, + workspaceId: workspace.id, + createdByUserId: actor.user.id, + })), + ], + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: directId, + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }) + baselinePrototypeIds.mockResolvedValue([baselineId]) + const prototypePageQueries = vi.spyOn(prisma.prototype, "findMany") + const anyPrototypeQueries = vi.spyOn(prisma.prototype, "findFirst") + + await expect( + service.getWorkspace("prototype-baseline-batch-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.listPrototypes("prototype-baseline-batch-token", workspace.id, {}), + ).resolves.toMatchObject({ + items: [{ id: directId }, { id: baselineId }], + nextCursor: null, + }) + + expect(baselinePrototypeIds).toHaveBeenCalledTimes(3) + expect( + baselinePrototypeIds.mock.calls.every( + ([database, userId, workspaceId]) => + database === prisma && userId === actor.user.id && workspaceId === workspace.id, + ), + ).toBe(true) + expect(baselineAccess).toHaveBeenCalledOnce() + expect(baselineAccess).toHaveBeenCalledWith(prisma, actor.user.id, baselineId) + const anyQuery = JSON.stringify(anyPrototypeQueries.mock.calls[0]?.[0]) + expect(anyQuery).toContain(workspace.id) + expect(anyQuery).toContain(actor.user.id) + expect(anyQuery).toContain(baselineId) + expect(prototypePageQueries).toHaveBeenCalledOnce() + const pageQuery = JSON.stringify(prototypePageQueries.mock.calls[0]?.[0]) + expect(pageQuery).toContain(workspace.id) + expect(pageQuery).toContain(actor.user.id) + expect(pageQuery).toContain(baselineId) + }) + it("gets a workspace through prototype or baseline access while an explicit workspace deny wins", async () => { const baselineUsers = new Set() const baselineAccess = vi diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 579dc150..5c134081 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -158,14 +158,30 @@ async function requireActiveTarget(database: Prisma.TransactionClient, userId: s async function preserveManualAdministration( database: Prisma.TransactionClient, + actor: Awaited>, userId: string, change: GrantInput, ): Promise { if (change.effect !== "allow" || change.role !== "administrator") return - await database.installationMembership.updateMany({ - where: { userId, source: "synchronized", status: "active" }, + const membership = await database.installationMembership.findUnique({ + where: { userId }, + select: { id: true, source: true, status: true }, + }) + if (membership?.source !== "synchronized" || membership.status !== "active") return + const changed = await database.installationMembership.updateMany({ + where: { id: membership.id, source: "synchronized", status: "active" }, data: { source: "manual" }, }) + if (changed.count === 0) return + await saveSecurityAudit(database, { + event: "membership.synchronized.preserve", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + targetMembershipId: membership.id, + targetUserId: userId, + context: { reason: "product_administration" }, + }) } async function lockWorkspace( @@ -450,10 +466,11 @@ async function readAccessiblePrototypePage({ cursor?: string limit: number }) { - if (!baseline) { - const accessWhere = workspaceGrant - ? { grants: { none: { userId, effect: "deny" as const } } } - : { grants: { some: { userId, effect: "allow" as const } } } + if (workspaceGrant?.effect === "allow" || !baseline) { + const accessWhere = + workspaceGrant?.effect === "allow" + ? { grants: { none: { userId, effect: "deny" as const } } } + : { grants: { some: { userId, effect: "allow" as const } } } const items = await database.prototype.findMany({ where: { workspaceId, @@ -465,11 +482,46 @@ async function readAccessiblePrototypePage({ }) return pageResult(items.map(prototypeDto), limit) } + const baselinePrototypeIds = baseline.prototypeIds + ? await baseline.prototypeIds(database, userId, workspaceId) + : [] + if (!baseline.prototypeIds) { + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.prototype.findMany({ + where: { workspaceId, ...(scanCursor ? { id: { gt: scanCursor } } : {}) }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + if ( + (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) + .allowed + ) { + items.push(prototypeDto(candidate)) + } + if (items.length > limit) break + } + if (candidates.length < 100) break + } + return pageResult(items, limit) + } const items = [] let scanCursor = cursor while (items.length <= limit) { const candidates = await database.prototype.findMany({ - where: { workspaceId, ...(scanCursor ? { id: { gt: scanCursor } } : {}) }, + where: { + workspaceId, + ...(scanCursor ? { id: { gt: scanCursor } } : {}), + OR: [ + { grants: { some: { userId, effect: "allow" } } }, + ...(baselinePrototypeIds.length > 0 ? [{ id: { in: baselinePrototypeIds } }] : []), + ], + }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, take: 100, @@ -507,36 +559,42 @@ async function hasAnyPrototypeAccess({ }) { const workspaceDecision = evaluatePrototypeAccess({ workspaceGrant }) if (workspaceDecision.source !== "none") return workspaceDecision.allowed - if (!baseline) { - return Boolean( - await database.prototype.findFirst({ - where: { - workspaceId, - grants: { some: { userId, effect: "allow" } }, - }, - select: { id: true }, - }), - ) - } - let cursor: string | undefined - while (true) { - const candidates = await database.prototype.findMany({ - where: { workspaceId, ...(cursor ? { id: { gt: cursor } } : {}) }, - include: { grants: { where: { userId } } }, - orderBy: { id: "asc" }, - take: 100, - }) - for (const candidate of candidates) { - if ( - (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) - .allowed - ) { - return true + const baselinePrototypeIds = baseline?.prototypeIds + ? await baseline.prototypeIds(database, userId, workspaceId) + : [] + if (baseline && !baseline.prototypeIds) { + let cursor: string | undefined + while (true) { + const candidates = await database.prototype.findMany({ + where: { workspaceId, ...(cursor ? { id: { gt: cursor } } : {}) }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + for (const candidate of candidates) { + if ( + (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) + .allowed + ) { + return true + } } + if (candidates.length < 100) return false + cursor = candidates.at(-1)?.id } - if (candidates.length < 100) return false - cursor = candidates.at(-1)?.id } + return Boolean( + await database.prototype.findFirst({ + where: { + workspaceId, + OR: [ + { grants: { some: { userId, effect: "allow" } } }, + ...(baselinePrototypeIds.length > 0 ? [{ id: { in: baselinePrototypeIds } }] : []), + ], + }, + select: { id: true }, + }), + ) } async function audit( @@ -680,7 +738,7 @@ export function createPrototypeAuthorizationService({ create: { workspaceId, userId: targetId, ...change }, update: change, }) - await preserveManualAdministration(transaction, targetUserId, change) + await preserveManualAdministration(transaction, actor, targetId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", workspaceId, @@ -940,7 +998,7 @@ export function createPrototypeAuthorizationService({ create: { prototypeId, userId: targetId, ...change }, update: change, }) - await preserveManualAdministration(transaction, targetId, change) + await preserveManualAdministration(transaction, actor, targetId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant", workspaceId: prototype.workspaceId, diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index 13cfc46f..ba9ad592 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -62,4 +62,9 @@ export interface PrototypeBaselinePolicy { database: Prisma.TransactionClient | PrismaClient, userId: string, ): Promise + prototypeIds?( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + workspaceId: string, + ): Promise } From 4bc4b931dbb51b72c1b79f047982a3d5487e1451 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 04:40:04 -0700 Subject: [PATCH 26/56] fix(hub): preserve reconciliation ordering Share per-user ordering across scans and sign-ins, filter enforcement candidates, retain partial confirmations, and log bounded scheduled failures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 4 +- hub/docs/testing-and-operations.md | 2 +- .../complete-github-authorization/index.ts | 2 +- .../create-service/index.test.ts | 86 ++++ .../create-service/index.db.test.ts | 390 +++++++++++++++++- .../create-service/index.ts | 206 ++++----- 6 files changed, 588 insertions(+), 102 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index 54ed5c58..a6515518 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -31,7 +31,7 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. Admission claims before resolving mutable repository metadata and rejects a source-generation change, so a rename or transfer cannot substitute a replacement repository. After GitHub replies, Hub locks and re-reads the relevant generations and applies results only when they are still current. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. These per-user claims use the repository row lock without taking the global identity-store lock. Admission claims before resolving mutable repository metadata and returns a retryable conflict when the source generation changed, so a rename or transfer cannot substitute a replacement repository or appear as permission loss. `POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. @@ -39,7 +39,7 @@ Each reconciliation claims a monotonically increasing source generation while ho A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 removes that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. -Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. One user's provider failure leaves that user's timestamp unchanged while other confirmed results apply; the source records a bounded partial-failure status and scheduled runs emit one static operational error record with the source ID and bounded reason. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 49450eb1..13e49cf8 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, source-generation changes during metadata resolution, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation shared by reconciliation and sign-in, identity mismatch, built-in role-name mapping, provider-response versus apply failures, credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and batched deletion events, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, long manual reconciliation attribution, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and audited administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, retryable source-generation changes during metadata resolution, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation shared by reconciliation and sign-in, first-enrollment rollback, per-user claim lock isolation, identity mismatch and partial provider failure, built-in role-name mapping, provider-response versus apply failures, credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and batched deletion events, replayed deliveries, missed-event repair, bounded request concurrency, filtered membership enforcement, single-flight scheduling, long manual reconciliation attribution and scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and audited administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index fc20d365..df8031c7 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -296,7 +296,7 @@ export async function completeGitHubAuthorization( invitationId: invitation.id, }) } else if (synchronizableMembership) { - await records.saveMembership({ + membership = await records.saveMembership({ userId: identity.userId, status: "active", role: "member", diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index 2533d311..62f77d19 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -521,6 +521,92 @@ describe("createAuthenticationService()", () => { }, ) + it("rejects a superseded first synchronized enrollment before creating its session", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const saveMembership = vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + const createSession = vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }) + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + saveMembership, + createSession, + }) + const saveBaseline = vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed.", + ), + ) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + subject: "42", + admissionGeneration: 4, + persist: true, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline, + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + + expect(saveMembership).toHaveBeenCalledOnce() + expect(saveBaseline).toHaveBeenCalledOnce() + expect(saveMembership.mock.invocationCallOrder[0]).toBeLessThan( + saveBaseline.mock.invocationCallOrder[0]!, + ) + expect(createSession).not.toHaveBeenCalled() + }) + it("propagates a baseline save failure other than a superseded authorization", async () => { const existingIdentity = { id: "identity", diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 6f94e904..ab05854f 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -10,9 +10,11 @@ import type { GitHubAuthorizationService, } from "#server/domains/github-authorization/create-service/types" import { createIdentityStore } from "#server/domains/identity/create-store" +import { logger } from "#server/integrations/pino" +import { AuthenticationError } from "#server/utils/authentication-error" import { digestSecret } from "#server/utils/digest-secret" -import type { PrismaClient } from "../../../generated/prisma/client.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" type IdentityStore = ReturnType @@ -542,7 +544,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ effect: "allow", role: "commenter" }) }) - it("rejects repository metadata observed before a source generation change", async () => { + it("reports a source generation change instead of treating admission as denied", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) const source = await prisma.gitHubRepositorySource.create({ @@ -614,7 +616,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { ) continueAdmission.resolve() - await expect(admission).resolves.toBeNull() + await expect(admission).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ "https://api.github.com/app/installations/7/access_tokens", ]) @@ -626,6 +631,66 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("claims reconciliation attempts without waiting for the identity-store lock", async () => { + const { prisma, service, store } = await database() + await createUser(store, { subject: "42" }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const lockHeld = deferred() + const releaseLock = deferred() + let permissionRequested = false + const lock = store.lock(async () => { + lockHeld.resolve() + await releaseLock.promise + }) + await lockHeld.promise + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequested = true + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), + ) + }), + ) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => expect(permissionRequested).toBe(true)) + await expect(prisma.gitHubRepositorySource.count()).resolves.toBe(1) + + releaseLock.resolve() + await Promise.all([lock, reconciliation]) + }) + it("does not start another scheduled reconciliation while one is running", async () => { const { prisma, service, store } = await database() await createUser(store, { subject: "42" }) @@ -1472,6 +1537,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { it("records source configuration mismatch as failed without refreshing confirmation", async () => { const { prisma, service, store } = await database() await store.lock(() => Promise.resolve()) + const errorLog = vi.spyOn(logger, "error").mockImplementation(() => undefined) const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") const source = await prisma.gitHubRepositorySource.create({ data: { @@ -1495,6 +1561,13 @@ describe("createGitHubAuthorizationService() database behavior", () => { synchronizationError: "repository_source_configuration_changed", lastConfirmedAt, }) + expect(errorLog).toHaveBeenCalledWith( + { + repositorySourceId: source.id, + reason: "repository_source_configuration_changed", + }, + "GitHub authorization reconciliation failed", + ) }) it("records credential reconciliation failure without refreshing confirmation", async () => { @@ -1947,6 +2020,219 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("keeps successful confirmations when another identity permission check fails", async () => { + const { prisma, service, store } = await database() + const confirmed = await createUser(store, { subject: "42" }) + const failed = await createUser(store, { subject: "43" }) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + await saveAdmissionFixture(prisma, service, failed.user.id, failed.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: lastConfirmedAt, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { + status: 200, + }), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: confirmed.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + admissionEligible: true, + mappedPrototypeRole: "viewer", + status: "active", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: failed.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + mappedPrototypeRole: "editor", + lastConfirmedAt, + status: "active", + }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "permission_partial_failure", + lastConfirmedAt, + }) + }) + + it("rolls back first synchronized enrollment when its baseline is superseded", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + }), + ) + const saveBaseline = vi.fn(async (transaction: Prisma.TransactionClient) => { + await expect( + transaction.installationMembership.findUnique({ + where: { id: target.membership.id }, + }), + ).resolves.toMatchObject({ source: "synchronized", status: "active" }) + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed.", + ) + }) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline, + }, + }) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + + expect(saveBaseline).toHaveBeenCalledOnce() + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: target.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + it("keeps a newer denied sign-in authoritative when an older grant responds later", async () => { const { prisma, service, store } = await database() const target = await createUser(store, { @@ -2615,6 +2901,104 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() }) + it("excludes fresh eligible users in the initial membership enforcement query", async () => { + const { prisma, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const [removedSource, eligibleSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "removed", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "eligible", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + let membershipQueries = 0 + let membershipCandidates = -1 + let baselineLookupQueries = 0 + let membershipWhere: unknown + const observedDatabase = prisma.$extends({ + query: { + installationMembership: { + async findMany({ args, query }) { + membershipQueries += 1 + membershipWhere = args.where + const result = await query(args) + membershipCandidates = result.length + return result + }, + }, + gitHubSynchronizedAccess: { + findFirst({ args, query }) { + baselineLookupQueries += 1 + return query(args) + }, + }, + }, + }) + const observedStore = createIdentityStore(observedDatabase as unknown as PrismaClient) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase as unknown as PrismaClient, + environment: environment(), + store: observedStore, + }) + await saveAdmissionFixture(prisma, observedService, actor.user.id, actor.identity.subject, { + repositorySourceId: eligibleSource.id, + sourceGeneration: eligibleSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + + await expect( + observedService.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: Number(removedSource.githubRepositoryId) }], + }, + "installation_repositories", + "preserve-fresh-eligible-membership", + ), + ), + ).resolves.toBe("accepted") + + expect(membershipQueries).toBe(1) + expect(membershipCandidates).toBe(0) + expect(baselineLookupQueries).toBe(0) + expect(JSON.stringify(membershipWhere)).toContain('"source":"synchronized"') + expect(JSON.stringify(membershipWhere)).toContain('"status":"active"') + expect(JSON.stringify(membershipWhere)).toContain('"admissionEligible":true') + expect(JSON.stringify(membershipWhere)).toContain('"admissionEnabled":true') + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "active" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).not.toBeNull() + }) + it("suspends linked sources without losing policy and resumes them dirty without baselines", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 35e11e8a..0e3ca204 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -20,6 +20,7 @@ import { } from "#server/integrations/github/check-repository-admission/constants" import { createInstallationToken } from "#server/integrations/github/create-installation-token" import { GitHubApiError } from "#server/integrations/github/github-api-error" +import { log } from "#server/integrations/pino/log" import { requestGitHubApi } from "#server/integrations/github/request-github-api" import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" @@ -284,82 +285,80 @@ async function enforceSynchronizedMemberships( ): Promise { const cutoff = new Date(at.getTime() - (await lastKnownGoodSeconds(database)) * 1_000) const synchronizedMemberships = await database.installationMembership.findMany({ - where: { source: "synchronized", status: "active" }, + where: { + source: "synchronized", + status: "active", + user: { + githubSynchronizedAccess: { + none: { + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + }, + }, + }, select: { id: true, userId: true, role: true }, }) for (const membership of synchronizedMemberships) { - const eligible = await database.gitHubSynchronizedAccess.findFirst({ - where: { - userId: membership.userId, - status: "active", - admissionEligible: true, - lastConfirmedAt: { gte: cutoff }, - repositorySource: { status: "active", admissionEnabled: true }, - }, - }) - if (!eligible) { - const [workspaceAdministration, prototypeAdministration] = await Promise.all([ - database.workspaceAccessGrant.findFirst({ - where: { - userId: membership.userId, - effect: "allow", - role: "administrator", - }, - select: { id: true }, - }), - database.prototypeAccessGrant.findFirst({ - where: { - userId: membership.userId, - effect: "allow", - role: "administrator", - }, - select: { id: true }, - }), - ]) - if ( - membership.role === "administrator" || - workspaceAdministration || - prototypeAdministration - ) { - const preserved = await database.installationMembership.updateMany({ - where: { - id: membership.id, - source: "synchronized", - status: "active", - }, - data: { source: "manual" }, - }) - if (preserved.count > 0) { - await saveSecurityAudit(database, { - event: "membership.synchronized.preserve", - actorUserId: null, - sessionId: null, - authenticationProviderRevisionId: null, - targetMembershipId: membership.id, - context: { reason: "manual_administration" }, - }) - } - continue - } - const removed = await database.installationMembership.updateMany({ + const [workspaceAdministration, prototypeAdministration] = await Promise.all([ + database.workspaceAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + database.prototypeAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + ]) + if (membership.role === "administrator" || workspaceAdministration || prototypeAdministration) { + const preserved = await database.installationMembership.updateMany({ where: { id: membership.id, source: "synchronized", status: "active", }, - data: { status: "removed", removedAt: at }, - }) - if (removed.count === 0) continue - await database.session.deleteMany({ where: { userId: membership.userId } }) - await saveSecurityAudit(database, { - event: "membership.synchronized.remove", - actorUserId: null, - sessionId: null, - authenticationProviderRevisionId: null, - targetMembershipId: membership.id, - context: { reason: "github_access_lost" }, + data: { source: "manual" }, }) + if (preserved.count > 0) { + await saveSecurityAudit(database, { + event: "membership.synchronized.preserve", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "manual_administration" }, + }) + } + continue } + const removed = await database.installationMembership.updateMany({ + where: { + id: membership.id, + source: "synchronized", + status: "active", + }, + data: { status: "removed", removedAt: at }, + }) + if (removed.count === 0) continue + await database.session.deleteMany({ where: { userId: membership.userId } }) + await saveSecurityAudit(database, { + event: "membership.synchronized.remove", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) } } @@ -461,25 +460,22 @@ export function createGitHubAuthorizationService({ observedSourceGeneration: number, requireAdmissionEnabled: boolean, ) { - return store.lock(async (_records, transaction) => { + return database.$transaction(async (transaction) => { await lockRepository(transaction, repositorySourceId) const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) - if ( - !source || - source.status !== "active" || - source.synchronizationGeneration !== observedSourceGeneration || - (requireAdmissionEnabled && !source.admissionEnabled) - ) { - return null + if (!source || source.status !== "active") return { status: "denied" as const } + if (requireAdmissionEnabled && !source.admissionEnabled) return { status: "denied" as const } + if (source.synchronizationGeneration !== observedSourceGeneration) { + return { status: "changed" as const } } const attempt = await transaction.gitHubAdmissionAttempt.upsert({ where: { repositorySourceId_subject: { repositorySourceId, subject } }, create: { repositorySourceId, subject, generation: 1 }, update: { generation: { increment: 1 } }, }) - return { source, admissionGeneration: attempt.generation } + return { status: "claimed" as const, source, admissionGeneration: attempt.generation } }) } @@ -550,22 +546,28 @@ export function createGitHubAuthorizationService({ source.synchronizationGeneration, false, ) - if (!claim) return null - const rawPermission = await permissionFor( - token, - identity.username!, - identity.subject, - repository.full_name, - repository.visibility, - ) - return { - userId: identity.userId, - subject: identity.subject, - admissionGeneration: claim.admissionGeneration, - rawPermission, - ...mapGitHubPermission(rawPermission), + if (claim.status !== "claimed") return null + try { + const rawPermission = await permissionFor( + token, + identity.username!, + identity.subject, + repository.full_name, + repository.visibility, + ) + return { + status: "confirmed" as const, + userId: identity.userId, + subject: identity.subject, + admissionGeneration: claim.admissionGeneration, + rawPermission, + ...mapGitHubPermission(rawPermission), + } + } catch { + return { status: "failed" as const } } }) + const partialFailure = results.some((result) => result?.status === "failed") phase = "apply" const applied = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) @@ -591,14 +593,14 @@ export function createGitHubAuthorizationService({ visibility: repository.visibility, status: "active", removedAt: null, - synchronizationStatus: "current", - synchronizationError: null, + synchronizationStatus: partialFailure ? "failed" : "current", + synchronizationError: partialFailure ? "permission_partial_failure" : null, dirtyAt: null, - lastConfirmedAt: confirmedAt, + ...(partialFailure ? {} : { lastConfirmedAt: confirmedAt }), }, }) for (const result of results) { - if (!result) continue + if (!result || result.status !== "confirmed") continue await saveBaseline( transaction, result.userId, @@ -794,7 +796,14 @@ export function createGitHubAuthorizationService({ source.synchronizationGeneration, true, ) - if (!claim) continue + if (claim.status === "changed") { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + if (claim.status === "denied") continue let repository: z.infer try { repository = repositorySchema.parse( @@ -1159,7 +1168,14 @@ export function createGitHubAuthorizationService({ try { await reconcile(source.id) } catch { - // The source record stores the explicit failure and bounded last-known-good state. + const failed = await database.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + select: { synchronizationError: true }, + }) + log("error", "GitHub authorization reconciliation failed", { + repositorySourceId: source.id, + reason: failed?.synchronizationError ?? "reconciliation_failed", + }) } } await store.lock((_records, transaction) => From 125e8e8e758b84c2b6242af15ebb6ecbea5c2824 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 07:57:53 -0700 Subject: [PATCH 27/56] fix(hub): close synchronized revocation races Revalidate cached admission transactionally, order per-result confirmations, revoke without source credentials, bound reconciliation work, and expose prototype policy state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 6 +- hub/docs/prototype-authorization.md | 2 + hub/docs/testing-and-operations.md | 2 +- .../complete-github-authorization/index.ts | 15 +- .../create-service/index.db.test.ts | 67 ++ .../create-service/index.db.test.ts | 693 +++++++++++++++++- .../create-service/index.ts | 216 ++++-- .../create-service/index.db.test.ts | 29 +- .../create-service/index.ts | 6 + 9 files changed, 987 insertions(+), 49 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index a6515518..686360b8 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -33,13 +33,13 @@ GitHub does not map to Hub installation administrator or prototype administrator Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. These per-user claims use the repository row lock without taking the global identity-store lock. Admission claims before resolving mutable repository metadata and returns a retryable conflict when the source generation changed, so a rename or transfer cannot substitute a replacement repository or appear as permission loss. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Revocation events match the signed installation ID against trusted linked-source records and do not require usable API credentials, so credential loss cannot block deletion or suspension. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Dirty events are coalesced into a bounded near-term retry so a scan superseded by frequent organization changes does not wait for the next 15-minute tick. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. ## Staleness and outages -A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 removes that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. +A successful confirmation records its own `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. A cache fallback is revalidated under the callback transaction before it can reactivate membership or create a session. Installation-token 404 removes matching sources rather than using cache. Credential and configuration failures record an explicit failed status without refreshing confirmation. Hub permits a last-known-good baseline for one day, then fails closed. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. -Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. One user's provider failure leaves that user's timestamp unchanged while other confirmed results apply; the source records a bounded partial-failure status and scheduled runs emit one static operational error record with the source ID and bounded reason. +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. Scans include only identities with active Hub membership, issue permission requests with bounded concurrency, and apply confirmations in bounded transactions outside the global identity lock. One user's provider failure leaves that user's timestamp unchanged while other confirmed results apply; the source records a bounded partial-failure status and scheduled runs emit one static operational error record with the source ID and bounded reason. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index b1886589..5ead8eca 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -41,6 +41,8 @@ The evaluator applies this order: Every decision reports whether access is allowed, the effective role, the source, and a stable reason. GitHub baseline decisions also report the repository source, immutable GitHub repository ID, raw permission, last confirmation time, and synchronization status. Routes use this evaluator instead of reimplementing precedence. Hub consults the synchronized baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. +Prototype read and list responses include `authorizationMode` and `sourceRepositoryId`. The authorization-policy mutation returns that same prototype shape, so administrators can inspect the effective configuration even when an explicit Hub role wins the access decision. + The fixed mapping is: | GitHub permission | Hub prototype baseline | diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 13e49cf8..f0ca5959 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, retryable source-generation changes during metadata resolution, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation shared by reconciliation and sign-in, first-enrollment rollback, per-user claim lock isolation, identity mismatch and partial provider failure, built-in role-name mapping, provider-response versus apply failures, credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and batched deletion events, replayed deliveries, missed-event repair, bounded request concurrency, filtered membership enforcement, single-flight scheduling, long manual reconciliation attribution and scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and audited administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover transactional cache revalidation, invitation precedence during source outage, immutable-ID rename and 404 invalidation, token-404 installation removal, credential-independent webhook revocation, retryable source-generation changes, per-result confirmation ordering, active-identity filtering, bounded provider requests and apply batches, partial scans, dirty-event retry coalescing, first-enrollment and admission-failure auditing, repository and installation lifecycle, provider-response versus apply failures, scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, manual suspension and audited administration preservation, compatibility membership isolation, synchronized session freshness, and rollback. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index df8031c7..340419fe 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -137,7 +137,16 @@ export async function completeGitHubAuthorization( !(membership?.status === "active" && membership.source !== "synchronized") && !invitation ) { - throw admissionFailure + await records.audit({ + event: "authentication.admission_failed", + outcome: "failed", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: admissionFailure.code }, + }) + return { failure: admissionFailure, purpose } } if ( purpose === "sign-in" && @@ -340,5 +349,9 @@ export async function completeGitHubAuthorization( if ("denied" in result && result.denied) { throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") } + if ("failure" in result) { + if (result.failure instanceof Error) throw result.failure + throw new AuthenticationError(503, "admission_failed", "Admission failed.") + } return result } diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 946bce3d..7ee74845 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -207,6 +207,73 @@ describe("createAuthenticationService() database behavior", () => { }, ) + it("commits an admission-policy failure audit before returning the provider error", async () => { + const { prisma, store } = await database() + const graph = await createAdministrator(store) + const now = new Date() + await prisma.installationMembership.update({ + where: { userId: graph.user.id }, + data: { role: "member", source: "synchronized" }, + }) + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(now.getTime() + 60_000), + }), + ) + const service = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: graph.identity.subject, + username: "octocat", + displayName: "Synthetic administrator", + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: graph.provider.id, + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockRejectedValue( + Object.assign(new Error("GitHub authorization changed."), { + status: 409, + code: "github_authorization_changed", + }), + ), + }, + now: () => now, + }) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + expect( + await prisma.securityAudit.findFirst({ + where: { event: "authentication.admission_failed" }, + }), + ).toMatchObject({ + outcome: "failed", + actorUserId: graph.user.id, + authenticationProviderRevisionId: graph.provider.id, + context: { reason: "source_integration_unavailable" }, + }) + expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(0) + }) + it("rolls back generation, session deletion, and audit together when revocation fails", async () => { const { store } = await database() const graph = await createAdministrator(store) diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index ab05854f..bffa8315 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks" import { createHmac, generateKeyPairSync } from "node:crypto" import { readFileSync } from "node:fs" import { describe, expect, it, onTestFinished, vi } from "vitest" @@ -2368,7 +2369,6 @@ describe("createGitHubAuthorizationService() database behavior", () => { const target = await createUser(store, { subject: "42", membershipSource: "synchronized", - status: "removed", }) const source = await prisma.gitHubRepositorySource.create({ data: { @@ -2580,7 +2580,6 @@ describe("createGitHubAuthorizationService() database behavior", () => { const target = await createUser(store, { subject: "42", membershipSource: "synchronized", - status: "removed", }) const lost = await createUser(store, { subject: "43", @@ -3164,6 +3163,696 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ actorUserId: administrator.user.id }) }) + it("revalidates cached fallback in the callback transaction after synchronized revocation", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "existing-session", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("fallback-state"), + browserDigest: digestSecret("fallback-browser"), + pkceVerifier: "fallback-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: async (profile) => { + const baseline = await service.evaluateAdmission(profile) + await service.processWebhook( + webhook( + { action: "deleted", installation: { id: 7 } }, + "installation", + "fallback-revocation", + ), + ) + return baseline + }, + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + await expect( + authentication.complete("code", "fallback-state", "fallback-browser"), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect(await prisma.session.count({ where: { userId: actor.user.id } })).toBe(0) + }) + + it("removes matching sources and synchronized membership before rejecting token 404 fallback", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-session", + }) + const [matchingSource, otherSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "matching", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "99999", + installationId: "8", + owner: "owner", + name: "other", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: matchingSource.id, + sourceGeneration: matchingSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 }))) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: matchingSource.id } }), + ).toMatchObject({ status: "removed", admissionEnabled: false }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: otherSource.id } }), + ).toMatchObject({ status: "active", admissionEnabled: true }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: matchingSource.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }) + + it.each([ + { action: "deleted", expectedSourceStatus: "removed" }, + { action: "suspend", expectedSourceStatus: "suspended" }, + ] as const)( + "uses the trusted linked installation for a signed $action webhook when credentials are unreadable", + async ({ action, expectedSourceStatus }) => { + const { prisma, service, store } = await database({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "database", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: `${action}-session`, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await store.lock((records) => + records.saveGitHubIntegrationCredentials("invalid-encrypted-credentials"), + ) + + await expect( + service.processWebhook( + webhook( + { action, installation: { id: 7 } }, + "installation", + `unreadable-credentials-${action}`, + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ status: expectedSourceStatus }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }, + ) + + it("reconciles only GitHub identities with an active installation membership", async () => { + const { prisma, service, store } = await database() + const active = await createUser(store, { subject: "42" }) + await createUser(store, { subject: "43", status: "suspended" }) + await createUser(store, { subject: "44", status: "removed" }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const permissionRequests: string[] = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequests.push(url) + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), + ) + }), + ) + + await service.reconcileDue() + + expect(permissionRequests).toHaveLength(1) + expect(permissionRequests[0]).toContain(active.identity.username!) + expect(permissionRequests[0]).not.toContain("user-43") + expect(permissionRequests[0]).not.toContain("user-44") + }) + + it("uses each result confirmation time so a later reconciliation denial beats sign-in", async () => { + const { prisma, service, store } = await database() + const users = [] + for (let index = 0; index < 11; index += 1) { + users.push(await createUser(store, { subject: String(100 + index) })) + } + users.sort((left, right) => left.identity.id.localeCompare(right.identity.id)) + const target = users.at(-1)! + await prisma.installationMembership.update({ + where: { id: target.membership.id }, + data: { source: "synchronized" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("later-denial-state"), + browserDigest: digestSecret("later-denial-browser"), + pkceVerifier: "later-denial-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const subjectByUsername = new Map( + users.map(({ identity }) => [identity.username!, identity.subject]), + ) + const firstPermissionBatch: Array<{ + subject: string + response: ReturnType> + }> = [] + let targetPermissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const username = decodeURIComponent( + url.match(/collaborators\/([^/]+)\/permission$/)?.[1] ?? "", + ) + const subject = subjectByUsername.get(username) + if (!subject) throw new Error(`Unexpected request: ${url}`) + if (username === target.identity.username) { + targetPermissionRequests += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: Number(subject) }, + permission: targetPermissionRequests === 1 ? "write" : "none", + }), + { status: 200 }, + ), + ) + } + const response = deferred() + firstPermissionBatch.push({ subject, response }) + return response.promise + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => expect(firstPermissionBatch).toHaveLength(10)) + const laterSignIn = await authentication.complete( + "code", + "later-denial-state", + "later-denial-browser", + ) + expect(laterSignIn.purpose).toBe("sign-in") + expect(typeof laterSignIn.sessionToken).toBe("string") + firstPermissionBatch.forEach(({ subject, response }) => + response.resolve( + new Response(JSON.stringify({ user: { id: Number(subject) }, permission: "read" }), { + status: 200, + }), + ), + ) + await reconciliation + + expect(targetPermissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: target.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + + it("applies confirmed identities in bounded transactions outside the identity lock", async () => { + const { prisma, store } = await database() + for (let index = 0; index < 51; index += 1) { + await createUser(store, { subject: String(1_000 + index) }) + } + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const transactionState = new AsyncLocalStorage<{ baselineWrites: number }>() + let identityLockDepth = 0 + let baselineWritesInsideIdentityLock = 0 + const observedDatabase = prisma.$extends({ + query: { + gitHubSynchronizedAccess: { + upsert({ args, query }) { + const state = transactionState.getStore() + if (state) state.baselineWrites += 1 + if (identityLockDepth > 0) baselineWritesInsideIdentityLock += 1 + return query(args) + }, + }, + }, + }) as unknown as PrismaClient + const originalTransaction = observedDatabase.$transaction.bind(observedDatabase) as unknown as ( + operation: (transaction: Prisma.TransactionClient) => Promise, + ) => Promise + const batchWriteCounts: number[] = [] + vi.spyOn(observedDatabase, "$transaction").mockImplementation( + async (operation: (transaction: Prisma.TransactionClient) => Promise) => { + const state = { baselineWrites: 0 } + const result = await originalTransaction((transaction) => + transactionState.run(state, () => operation(transaction)), + ) + if (state.baselineWrites > 0) batchWriteCounts.push(state.baselineWrites) + return result + }, + ) + const observedStore = createIdentityStore(observedDatabase) + const originalLock = observedStore.lock.bind(observedStore) + vi.spyOn(observedStore, "lock").mockImplementation((operation) => + originalLock(async (...args) => { + identityLockDepth += 1 + try { + return await operation(...args) + } finally { + identityLockDepth -= 1 + } + }), + ) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase, + environment: environment(), + store: observedStore, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const subject = url.match(/collaborators\/user-(\d+)\/permission$/)?.[1] + if (!subject) throw new Error(`Unexpected request: ${url}`) + return Promise.resolve( + new Response(JSON.stringify({ user: { id: Number(subject) }, permission: "read" }), { + status: 200, + }), + ) + }), + ) + + await observedService.reconcileDue() + + expect(batchWriteCounts).toEqual([50, 1]) + expect(baselineWritesInsideIdentityLock).toBe(0) + expect( + await prisma.gitHubSynchronizedAccess.count({ where: { repositorySourceId: source.id } }), + ).toBe(51) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "current", + }) + }) + + it("logs a returned partial reconciliation status without requiring a thrown error", async () => { + const { prisma, service, store } = await database() + await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const errorLog = vi.spyOn(logger, "error").mockImplementation(() => undefined) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect(errorLog).toHaveBeenCalledWith( + { + repositorySourceId: source.id, + reason: "permission_partial_failure", + }, + "GitHub authorization reconciliation failed", + ) + }) + + it("coalesces repeated dirty webhooks into one bounded reconciliation retry", async () => { + const { prisma, service } = await database() + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + }, + }) + const repositoryRequested = deferred() + const fetchMock = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequested.resolve() + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", fetchMock) + for (const deliveryId of ["dirty-one", "dirty-two", "dirty-three"]) { + await expect( + service.processWebhook( + webhook({ action: "edited", installation: { id: 7 } }, "membership", deliveryId), + ), + ).resolves.toBe("accepted") + } + + expect(fetchMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(999) + expect(fetchMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await repositoryRequested.promise + + expect( + fetchMock.mock.calls.filter(([input]) => requestUrl(input).endsWith("/access_tokens")), + ).toHaveLength(1) + expect( + fetchMock.mock.calls.filter(([input]) => requestUrl(input).endsWith("/repositories/100")), + ).toHaveLength(1) + await vi.waitFor(async () => { + await expect( + prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).resolves.toMatchObject({ synchronizationStatus: "current" }) + }) + }) + it("keeps the synchronized authorization migration constraints and compatibility backfill", () => { const migration = readFileSync( new URL( diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 0e3ca204..dee52692 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -190,7 +190,6 @@ async function saveBaseline( skipOlderConfirmation = false, requireAdmissionAttempt = true, ): Promise<"saved" | "superseded"> { - if (!baseline.persist) return "saved" await lockRepository(database, baseline.repositorySourceId) const source = await database.gitHubRepositorySource.findUnique({ where: { id: baseline.repositorySourceId }, @@ -212,6 +211,32 @@ async function saveBaseline( "GitHub authorization changed. Start again.", ) } + if (!baseline.persist) { + const cutoff = new Date(Date.now() - (await lastKnownGoodSeconds(database)) * 1_000) + const cached = await database.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + }) + if ( + !cached || + cached.status !== "active" || + !cached.admissionEligible || + cached.rawPermission !== baseline.rawPermission || + cached.lastConfirmedAt.getTime() !== baseline.confirmedAt.getTime() || + cached.lastConfirmedAt < cutoff + ) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + return "saved" + } if (requireAdmissionAttempt) { const attempt = await database.gitHubAdmissionAttempt.findUnique({ where: { @@ -449,11 +474,25 @@ export function createGitHubAuthorizationService({ throw new Error("HUB_GITHUB_WEBHOOK_SECRET must contain between 20 and 1024 characters.") } let reconciliationDueRunning = false + const dirtyRetrySources = new Set() + let dirtyRetryTimer: NodeJS.Timeout | null = null async function sourceCredentials() { return credentials(store, createGitHubIntegrationCredentialProvider(environment)) } + function scheduleDirtyReconciliation(sourceIds: string[]) { + for (const sourceId of sourceIds) dirtyRetrySources.add(sourceId) + if (dirtyRetryTimer || dirtyRetrySources.size === 0) return + dirtyRetryTimer = setTimeout(() => { + const sourceIds = [...dirtyRetrySources] + dirtyRetrySources.clear() + dirtyRetryTimer = null + void Promise.allSettled(sourceIds.map((sourceId) => reconcile(sourceId))) + }, 1_000) + dirtyRetryTimer.unref() + } + async function claimAdmissionGeneration( repositorySourceId: string, subject: string, @@ -492,6 +531,7 @@ export function createGitHubAuthorizationService({ "The repository link was not found.", ) } + const attemptedAt = new Date() return transaction.gitHubRepositorySource.update({ where: { id: current.id }, @@ -528,7 +568,12 @@ export function createGitHubAuthorizationService({ let identityCursor: string | undefined while (true) { const page = await database.externalIdentity.findMany({ - where: { provider: "github", issuer: "https://github.com", username: { not: null } }, + where: { + provider: "github", + issuer: "https://github.com", + username: { not: null }, + user: { memberships: { some: { status: "active" } } }, + }, select: { id: true, userId: true, subject: true, username: true }, orderBy: { id: "asc" }, ...(identityCursor ? { cursor: { id: identityCursor }, skip: 1 } : {}), @@ -538,7 +583,6 @@ export function createGitHubAuthorizationService({ if (page.length < 100) break identityCursor = page.at(-1)?.id } - const confirmedAt = new Date() const results = await mapWithConcurrency(identities, 10, async (identity) => { const claim = await claimAdmissionGeneration( source.id, @@ -561,6 +605,7 @@ export function createGitHubAuthorizationService({ subject: identity.subject, admissionGeneration: claim.admissionGeneration, rawPermission, + confirmedAt: new Date(), ...mapGitHubPermission(rawPermission), } } catch { @@ -568,7 +613,58 @@ export function createGitHubAuthorizationService({ } }) const partialFailure = results.some((result) => result?.status === "failed") + const confirmedAt = results.reduce( + (latest, result) => + result?.status === "confirmed" && + (!latest || result.confirmedAt.getTime() > latest.getTime()) + ? result.confirmedAt + : latest, + null, + ) phase = "apply" + const actor = actorSessionToken + ? await currentAdministrator(store, actorSessionToken, undefined, false) + : null + const confirmedResults = results.filter( + (result): result is NonNullable & { status: "confirmed" } => + result?.status === "confirmed", + ) + for (let index = 0; index < confirmedResults.length; index += 50) { + const batch = confirmedResults.slice(index, index + 50) + await database.$transaction(async (transaction) => { + await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return + } + for (const result of batch) { + await saveBaseline( + transaction, + result.userId, + { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + subject: result.subject, + admissionGeneration: result.admissionGeneration, + persist: true, + rawPermission: result.rawPermission, + admissionEligible: result.admissionEligible, + prototypeRole: result.prototypeRole, + confirmedAt: result.confirmedAt, + }, + false, + true, + true, + ) + } + }) + } const applied = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ @@ -581,9 +677,9 @@ export function createGitHubAuthorizationService({ ) { return false } - const actor = actorSessionToken - ? await currentAdministrator(store, actorSessionToken, transaction, false) - : null + if (actorSessionToken) { + await currentAdministrator(store, actorSessionToken, transaction, false) + } await transaction.gitHubRepositorySource.update({ where: { id: source.id }, data: { @@ -596,31 +692,10 @@ export function createGitHubAuthorizationService({ synchronizationStatus: partialFailure ? "failed" : "current", synchronizationError: partialFailure ? "permission_partial_failure" : null, dirtyAt: null, - ...(partialFailure ? {} : { lastConfirmedAt: confirmedAt }), + ...(!partialFailure && confirmedAt ? { lastConfirmedAt: confirmedAt } : {}), }, }) - for (const result of results) { - if (!result || result.status !== "confirmed") continue - await saveBaseline( - transaction, - result.userId, - { - repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, - subject: result.subject, - admissionGeneration: result.admissionGeneration, - persist: true, - rawPermission: result.rawPermission, - admissionEligible: result.admissionEligible, - prototypeRole: result.prototypeRole, - confirmedAt, - }, - false, - true, - true, - ) - } - await enforceSynchronizedMemberships(transaction, confirmedAt) + await enforceSynchronizedMemberships(transaction, confirmedAt ?? new Date()) await saveSecurityAudit(transaction, { event: "github.repository.reconcile", actorUserId: actor?.userId ?? null, @@ -741,12 +816,46 @@ export function createGitHubAuthorizationService({ ) const membership = identity?.user.memberships[0] let integration: GitHubCredentials - let token: string try { integration = await sourceCredentials() + } catch (error) { + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + throw error instanceof AuthenticationError + ? error + : new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + let token: string + try { token = await createInstallationToken(integration) } catch (error) { - if (error instanceof GitHubApiError) { + if (error instanceof GitHubApiError && error.status === 404) { + await store.lock(async (_records, transaction) => { + for (const source of sources) { + if ( + source.appId !== integration.appId || + source.installationId !== integration.installationId + ) { + continue + } + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + current?.status === "active" && + current.synchronizationGeneration === source.synchronizationGeneration + ) { + await removeRepositorySource(transaction, source.id, new Date(), false) + } + } + await enforceSynchronizedMemberships(transaction, new Date()) + }) + } else if (error instanceof GitHubApiError) { for (const source of sources) { if ( source.appId !== integration!.appId || @@ -1166,7 +1275,13 @@ export function createGitHubAuthorizationService({ }) for (const source of sources) { try { - await reconcile(source.id) + const reconciled = await reconcile(source.id) + if (reconciled?.synchronizationStatus === "failed") { + log("error", "GitHub authorization reconciliation failed", { + repositorySourceId: source.id, + reason: reconciled.synchronizationError ?? "reconciliation_failed", + }) + } } catch { const failed = await database.gitHubRepositorySource.findUnique({ where: { id: source.id }, @@ -1241,10 +1356,16 @@ export function createGitHubAuthorizationService({ "The GitHub webhook is invalid.", ) } - const integration = await sourceCredentials() - if (String(payload.data.installation.id) !== integration.installationId) return "ignored" + const installationId = String(payload.data.installation.id) try { - return await store.lock(async (_records, transaction) => { + const processed = await store.lock(async (_records, transaction) => { + const linkedInstallation = await transaction.gitHubRepositorySource.findFirst({ + where: { installationId }, + select: { id: true }, + }) + if (!linkedInstallation) { + return { result: "ignored" as const, dirtySourceIds: [] } + } const repositoryId = payload.data.repository?.id const source = repositoryId ? await transaction.gitHubRepositorySource.findUnique({ @@ -1256,7 +1377,7 @@ export function createGitHubAuthorizationService({ deliveryId, event, action: payload.data.action ?? null, - installationId: integration.installationId, + installationId, repositorySourceId: source?.id ?? null, }, }) @@ -1264,7 +1385,7 @@ export function createGitHubAuthorizationService({ if (event === "installation" && payload.data.action === "deleted") { const sources = await transaction.gitHubRepositorySource.findMany({ where: { - installationId: integration.installationId, + installationId, status: { in: ["active", "suspended"] }, }, select: { id: true }, @@ -1274,9 +1395,9 @@ export function createGitHubAuthorizationService({ } await enforceSynchronizedMemberships(transaction, now) } else if (event === "installation" && payload.data.action === "suspend") { - await suspendInstallationSources(transaction, integration.installationId, now) + await suspendInstallationSources(transaction, installationId, now) } else if (event === "installation" && payload.data.action === "unsuspend") { - await resumeInstallationSources(transaction, integration.installationId, now) + await resumeInstallationSources(transaction, installationId, now) } else { const dirtyIds = new Set() const removedIds = new Set() @@ -1312,7 +1433,7 @@ export function createGitHubAuthorizationService({ }) } else if (removedIds.size === 0) { await transaction.gitHubRepositorySource.updateMany({ - where: { installationId: integration.installationId, status: "active" }, + where: { installationId, status: "active" }, data: { synchronizationGeneration: { increment: 1 }, synchronizationStatus: "dirty", @@ -1329,8 +1450,21 @@ export function createGitHubAuthorizationService({ githubRepositorySourceId: source?.id ?? null, context: { source: "github_webhook" }, }) - return "accepted" as const + const dirtySources = await transaction.gitHubRepositorySource.findMany({ + where: { + installationId, + status: "active", + synchronizationStatus: "dirty", + }, + select: { id: true }, + }) + return { + result: "accepted" as const, + dirtySourceIds: dirtySources.map(({ id }) => id), + } }) + scheduleDirtyReconciliation(processed.dirtySourceIds) + return processed.result } catch (error) { if ( error instanceof Error && diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 3d9b0b38..d688e6c6 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -240,7 +240,15 @@ describe("createPrototypeAuthorizationService() database behavior", () => { "authorization-administrator-token", workspace.id, { name: "Prototype" }, - )) as { id: string } + )) as { + id: string + authorizationMode: string + sourceRepositoryId: string | null + } + expect(prototype).toMatchObject({ + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -304,6 +312,23 @@ describe("createPrototypeAuthorizationService() database behavior", () => { authorizationMode: "github-synchronized", sourceRepositoryId: source.id, }) + await expect( + service.getPrototype("authorization-administrator-token", prototype.id), + ).resolves.toMatchObject({ + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }) + await expect( + service.listPrototypes("authorization-administrator-token", workspace.id, {}), + ).resolves.toMatchObject({ + items: [ + expect.objectContaining({ + id: prototype.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }), + ], + }) expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( { authorizationMode: "github-synchronized", @@ -1975,10 +2000,12 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(Object.keys(page.items[0] ?? {}).sort()).toEqual( [ "archivedAt", + "authorizationMode", "createdAt", "createdByUserId", "id", "name", + "sourceRepositoryId", "updatedAt", "workspaceId", ].sort(), diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 5c134081..a370576c 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -353,6 +353,8 @@ function prototypeDto(prototype: { workspaceId: string createdByUserId: string name: string + authorizationMode: string + sourceRepositoryId: string | null archivedAt: Date | null createdAt: Date updatedAt: Date @@ -362,6 +364,8 @@ function prototypeDto(prototype: { workspaceId: prototype.workspaceId, createdByUserId: prototype.createdByUserId, name: prototype.name, + authorizationMode: prototype.authorizationMode, + sourceRepositoryId: prototype.sourceRepositoryId, archivedAt: prototype.archivedAt, createdAt: prototype.createdAt, updatedAt: prototype.updatedAt, @@ -864,6 +868,8 @@ export function createPrototypeAuthorizationService({ workspaceId: prototype.workspaceId, createdByUserId: prototype.createdByUserId, name: prototype.name, + authorizationMode: prototype.authorizationMode, + sourceRepositoryId: prototype.sourceRepositoryId, archivedAt: prototype.archivedAt, createdAt: prototype.createdAt, updatedAt: prototype.updatedAt, From 0bb8f679cd3f1d818058bd892eb9fb43cb17dbd3 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 16:07:19 -0700 Subject: [PATCH 28/56] feat(hub): add provider-neutral artifact ingestion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 3 + hub/docs/README.md | 1 + hub/docs/architecture.md | 6 +- hub/docs/artifact-ingestion.md | 84 +++ hub/docs/database.md | 6 +- hub/docs/identity.md | 10 +- hub/docs/logging.md | 4 +- hub/docs/prototype-authorization.md | 28 +- hub/docs/testing-and-operations.md | 6 +- hub/package.json | 1 + hub/schema/artifact-publication.prisma | 24 + .../migration.sql | 125 ++++ hub/schema/prototype-channel.prisma | 21 + hub/schema/prototype-version.prisma | 32 +- hub/schema/prototype.prisma | 11 +- hub/schema/security-audit.prisma | 2 + hub/schema/workload-credential.prisma | 20 + hub/schema/workload-publisher.prisma | 23 + hub/script/publish-artifact.mjs | 70 +++ hub/server/create-app/index.ts | 3 + .../create-service/constants.ts | 2 + .../create-service/index.db.test.ts | 595 ++++++++++++++++++ .../create-service/index.ts | 375 +++++++++++ .../create-service/types.ts | 38 ++ .../authentication/create-runtime/index.ts | 11 + .../create-service/index.db.test.ts | 118 ---- .../create-service/index.ts | 174 +++-- .../create-service/types.ts | 6 +- .../create-local-storage/index.test.ts | 67 ++ .../create-local-storage/index.ts | 96 +++ .../integrations/artifact-storage/types.ts | 10 + .../security-audits/save-audit/index.ts | 1 + .../repositories/security-audits/types.ts | 2 + hub/server/routes/index.ts | 36 +- hub/server/routes/product/index.test.ts | 14 +- hub/server/routes/product/index.ts | 26 +- hub/server/routes/publishing.test.ts | 235 +++++++ hub/server/routes/publishing/index.ts | 58 ++ hub/server/routes/types.ts | 2 + 39 files changed, 2116 insertions(+), 230 deletions(-) create mode 100644 hub/docs/artifact-ingestion.md create mode 100644 hub/schema/artifact-publication.prisma create mode 100644 hub/schema/migrations/20261027230000_artifact_ingestion/migration.sql create mode 100644 hub/schema/prototype-channel.prisma create mode 100644 hub/schema/workload-credential.prisma create mode 100644 hub/schema/workload-publisher.prisma create mode 100644 hub/script/publish-artifact.mjs create mode 100644 hub/server/domains/artifact-publishing/create-service/constants.ts create mode 100644 hub/server/domains/artifact-publishing/create-service/index.db.test.ts create mode 100644 hub/server/domains/artifact-publishing/create-service/index.ts create mode 100644 hub/server/domains/artifact-publishing/create-service/types.ts create mode 100644 hub/server/integrations/artifact-storage/create-local-storage/index.test.ts create mode 100644 hub/server/integrations/artifact-storage/create-local-storage/index.ts create mode 100644 hub/server/integrations/artifact-storage/types.ts create mode 100644 hub/server/routes/publishing.test.ts create mode 100644 hub/server/routes/publishing/index.ts diff --git a/hub/.env.example b/hub/.env.example index e75caf95..105360e7 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -16,6 +16,9 @@ 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 +# Local reference artifact storage. Production deployments should mount durable storage here. +# HUB_ARTIFACT_STORAGE_PATH=/var/lib/replica/artifacts + # GitHub user authorization is optional until both values are configured. # These credentials establish user identity and are separate from source integration. # HUB_GITHUB_AUTH_CLIENT_ID= diff --git a/hub/docs/README.md b/hub/docs/README.md index 8f9b9166..9fb427b6 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -11,6 +11,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [GitHub integration](github-integration.md): linked repository sources, reconciliation, webhooks, staleness, and permission mapping. - [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. - [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, Hub overrides, synchronized baselines, and policy precedence. +- [Artifact ingestion](artifact-ingestion.md): workload publishers, the versioned manifest, storage, immutable publishing, idempotency, and promotion. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 566ce451..58296e24 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, Hub-native prototype authorization, and opt-in GitHub-synchronized admission and prototype baselines. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, Hub-native prototype authorization, opt-in GitHub-synchronized admission and prototype baselines, and provider-neutral artifact publishing. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page 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 system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, synchronized GitHub baselines, and one policy evaluator. The [GitHub integration](github-integration.md) stores stable repository source records and reconciles current permissions with installation credentials. Deployment and general background-job infrastructure have not been built. This app does not ingest artifacts, host previews, browse source, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, synchronized GitHub baselines, and one policy evaluator. The [GitHub integration](github-integration.md) stores stable repository source records and reconciles current permissions with installation credentials. The [artifact ingestion system](artifact-ingestion.md) authenticates prototype-scoped workloads, validates a shared manifest, stores bytes behind an adapter, creates immutable versions, and moves promotion pointers. Deployment and general background-job infrastructure have not been built. This app does not host previews, browse source, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Product routes create and read workspaces, prototypes, and immutable versions; archive prototypes; choose Hub-managed or GitHub-synchronized prototype authorization; explain effective access; and administer direct access rules. Administration routes link repository sources, opt them into admission, inspect synchronization state, and trigger reconciliation. A verified GitHub App webhook route marks affected sources dirty or removed. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, prototype authorization, and GitHub synchronization runtime, starts the API at a local address, and schedules bounded periodic reconciliation. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, change membership roles or lifecycle, link repository sources, opt them into admission, inspect synchronization state, and trigger reconciliation. Product routes create and read workspaces and prototypes; read immutable versions; provision workload publishers; archive prototypes; choose Hub-managed or GitHub-synchronized prototype authorization; explain effective access; and administer direct access rules. Publishing routes ingest artifacts, read idempotent status, and move `current` or `preview`. A verified GitHub App webhook route marks affected sources dirty or removed. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, prototype authorization, GitHub synchronization, and artifact publishing runtime, starts the API at a local address, and schedules bounded periodic reconciliation. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. diff --git a/hub/docs/artifact-ingestion.md b/hub/docs/artifact-ingestion.md new file mode 100644 index 00000000..b2c5bdd1 --- /dev/null +++ b/hub/docs/artifact-ingestion.md @@ -0,0 +1,84 @@ +# Artifact ingestion and prototype versions + +Hub accepts one provider-neutral artifact contract from workload producers. GitHub Actions, Azure DevOps, Jenkins, local scripts, Copilot workflows, and other systems use the same API. Producers build outside Hub; Hub authenticates the workload, validates and stores the bytes, creates an immutable prototype version, records provenance, and may move a channel pointer. + +## Identity and access + +A prototype administrator creates a workload publisher with one or both actions: + +| Action | Authority | +| ------------- | ---------------------------------------------------------------------------- | +| `publish` | Publish an artifact and move the prototype's `current` or `preview` channel. | +| `read-status` | Read the result associated with that publisher's idempotency key. | + +Hub returns a bearer credential once and stores only its SHA-256 digest. The credential is scoped to one prototype through its workload publisher. It cannot create a browser session, view a prototype through user APIs, administer access, or publish to another prototype. A producer's source URL, revision, ref, pipeline, and run ID describe where a build came from; they never grant access. + +Browser users provision credentials through `POST /api/prototypes/:prototypeId/publishers`. This requires a fresh prototype-administrator session and returns the raw token only in that response. + +## Manifest contract + +`POST /api/publishing/prototypes/:prototypeId/versions` requires `Authorization: Bearer ...`, an `Idempotency-Key` header, and this JSON shape: + +```json +{ + "manifest": { + "schemaVersion": 1, + "prototypeId": "00000000-0000-0000-0000-000000000000", + "artifact": { + "format": "replica.prototype.bundle", + "version": "1", + "digest": "sha256:<64 lowercase hexadecimal characters>", + "sizeBytes": 1234, + "builtAt": "2026-10-27T20:00:00.000Z" + }, + "provenance": { + "provider": "jenkins", + "sourceUrl": "https://ci.example/jobs/prototype", + "revision": "abc123", + "ref": "refs/heads/main", + "pipeline": "prototype-build", + "runId": "451" + } + }, + "artifactBase64": "", + "promoteChannel": "preview" +} +``` + +The manifest is closed to unknown fields. Hub bounds every string, accepts only HTTP or HTTPS source URLs, limits decoded artifacts to 8 MiB in this reference layer, verifies the decoded size, and recalculates the SHA-256 digest before storing bytes. The manifest prototype ID must match the route. + +## Storage boundary + +`ArtifactStorage` separates ingestion from byte storage. The reference adapter writes content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`, defaulting to `hub/.data/artifacts` for local use. Database rows store only the storage key and validated metadata, never the artifact bytes. + +The local adapter is suitable for one Hub process with a durable mounted filesystem. It does not provide replication, remote durability, encryption-key management, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. + +Storage happens before the database transaction. A failed transaction can therefore leave an unreferenced content-addressed file. Hub never deletes referenced bytes automatically. Operators may remove only files that have no matching `PrototypeVersion.storageKey`; retain unreferenced files long enough to exclude in-flight transactions and backups. Version metadata has no deletion API and must not be rewritten silently. + +## Idempotency and immutability + +Hub hashes the idempotency key and stores it with a digest of the validated publication request. Retrying the same request with the same publisher and key returns the existing version. Reusing the key for different manifest or promotion input returns `idempotency_conflict`. Publishing the same content digest again to the prototype returns `artifact_already_published`. + +Publishing locks the prototype while it assigns the next sequence. Concurrent requests therefore create one ordered history. PostgreSQL rejects every update or deletion of a prototype-version row, including accidental writes outside the application. + +## Promotion and rollback + +`current` and `preview` are mutable pointers to immutable versions. A publish request may move one pointer in the same transaction that creates the version. `PUT /api/publishing/prototypes/:prototypeId/channels/:channel` moves a pointer to any version owned by that prototype. Rollback uses that endpoint to point the channel at an earlier version; it never mutates the version. + +## Status, audits, and failures + +`GET /api/publishing/prototypes/:prototypeId/status` reads the publication associated with the caller's `Idempotency-Key`. It requires `read-status`. + +Version creation, idempotency ownership, optional promotion, and the `prototype.version.publish` security audit commit in one transaction. Promotion writes `prototype.channel.promote` in its transaction. Audit failure rolls back database state. Responses expose validated version metadata and stable error codes, not credentials, storage paths, or artifact contents. Publishing routes omit headers and request payloads from structured request logs. + +## Local producer + +The local CLI proves that publishing does not depend on GitHub: + +```sh +cd hub +REPLICA_PUBLISH_TOKEN=... npm run publish:artifact -- \ + http://127.0.0.1:3001 PROTOTYPE_ID ./prototype.tar preview +``` + +Optional `REPLICA_PROVENANCE_PROVIDER`, `REPLICA_SOURCE_URL`, `REPLICA_REVISION`, `REPLICA_REF`, `REPLICA_PIPELINE`, and `REPLICA_RUN_ID` values populate provenance without changing authorization. diff --git a/hub/docs/database.md b/hub/docs/database.md index 6f8b7897..e8dd7624 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. The schema stores the provider-neutral [identity foundation](identity.md) and the [prototype authorization](prototype-authorization.md) product core. +Hub uses PostgreSQL and Prisma. The schema stores the provider-neutral [identity foundation](identity.md), the [prototype authorization](prototype-authorization.md) product core, and [artifact publication metadata](artifact-ingestion.md). [Compose](../.devcontainer/compose.yaml) pins the development database image. [package.json](../package.json) pins the Prisma packages to the same version. Dependency updates must follow the [seven-day dependency policy](../../docs/testing-and-review.md#update-dependencies). @@ -55,8 +55,10 @@ GitHub synchronization uses separate records: - `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. - `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. - `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. - `Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. +`Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. + +A database trigger rejects prototype version updates and deletions so history cannot be rewritten or silently removed. Workload credentials store token digests only. Publication rows bind a publisher-scoped idempotency-key digest to one version, and channel rows keep mutable pointers separate from immutable version metadata. Artifact bytes stay outside relational columns behind the storage boundary. Installation membership `source` distinguishes linked-source synchronization from the `legacy-synchronized` compatibility policy. Any administrator lifecycle change or assignment of Hub administration converts a synchronized membership to `manual`, so reconciliation cannot undo the Hub override or orphan administration. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index d1f547d6..e17e20b3 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -18,6 +18,8 @@ Hub sessions use a random opaque cookie and store only its digest. Authenticatio `legacy-repository` preserves the previous compatibility behavior. It scans repositories available to the installation and creates a `legacy-synchronized` member assignment after finding any repository permission, without requiring linked source records. The migration backfills pre-existing synchronized compatibility memberships to that source before linked-source synchronization begins. Linked-source reconciliation does not manage those compatibility assignments. It is not the normal synchronized authorization mode. The GitHub authentication adapter itself never reads installation credentials or performs repository authorization. +Artifact publishing uses a separate workload identity boundary. Prototype administrators issue prototype-scoped credentials with `publish` and/or `read-status`; Hub returns the raw bearer token once and stores only its digest. Workload credentials never create browser sessions or inherit user, installation, repository, or source-provider access. See [Artifact ingestion](artifact-ingestion.md). + Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. ## Persisted boundaries @@ -45,10 +47,10 @@ Administrator mutations require an active administrator session verified less th 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 | -| --- | --- | +| Provider | Credentials | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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. | +| `environment` | Set every `GITHUB_*` value, including `GITHUB_INSTALLATION_ID`. This provider is read-only and ignores database 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. A deployment may use the same GitHub App for user authorization and source integration, but it supplies and rotates the two configurations independently. Set `HUB_GITHUB_WEBHOOK_SECRET` separately when enabling the webhook route. @@ -56,6 +58,6 @@ Database credentials use AES-256-GCM with contextual additional authenticated da ## Current scope -This layer adds GitHub sign-in, Hub-managed and opt-in GitHub-synchronized installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary may consume a synchronized repository baseline without treating installation membership as product authority. Identity does not add artifact ingestion, production OIDC, account linking, or SCIM. +This layer adds GitHub sign-in, Hub-managed and opt-in GitHub-synchronized installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary may consume a synchronized repository baseline without treating installation membership as product authority. Artifact ingestion composes with identity only when a browser administrator provisions a workload credential; publication itself does not use a browser session. Identity does not add production OIDC, account linking, or SCIM. Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/logging.md b/hub/docs/logging.md index e66f3d3c..e30872e6 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,7 +30,7 @@ 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. -`createApp()` installs `omitRequestLogPayload` for authentication and session paths after request logging but before JSON parsing. This ordering covers parser failures as well as later origin checks, rate limits, and handlers: +`createApp()` installs `omitRequestLogPayload` for authentication, session, administration, product, and publishing paths after request logging but before JSON parsing. This ordering covers parser failures as well as later origin checks, rate limits, and handlers: ```ts import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" @@ -40,7 +40,7 @@ app.use(["/api/auth", "/api/session"], omitRequestLogPayload) This omits `params`, `query`, and `body` while keeping request metadata. -OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, and unrelated private fields therefore never enter automatic request payload records, including when JSON is malformed or too large. +OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, workload credentials, artifact manifests, and artifact bytes therefore never enter automatic request payload records, including when JSON is malformed or too large. Automatic records never include request headers, so bearer credentials are omitted as well. ## Structured-field safety diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 5ead8eca..1bf32fe2 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -8,20 +8,21 @@ A workspace owns prototypes. A prototype owns an ordered history of immutable ve - An active installation administrator may create a workspace. Creation also writes an explicit workspace `administrator` grant for that user in the same transaction. - A workspace `editor` or `administrator` may create a prototype. -- A prototype `editor` or `administrator` may create its next version. -- A version cannot move to another prototype or be changed after creation. -- Archiving a prototype keeps its versions readable but prevents new versions. Archive and version creation lock and re-read the prototype so overlapping requests cannot add a version after archival. +- A prototype administrator may provision a prototype-scoped workload publisher. +- An authenticated workload publisher with `publish` creates the next version through the [artifact ingestion contract](artifact-ingestion.md). +- A version cannot move to another prototype or be changed or deleted after creation. +- Archiving a prototype keeps its versions readable but prevents new versions. Archive and artifact publication lock and re-read the prototype so overlapping requests cannot add a version after archival. -Artifact payloads are not part of this layer. A version identifies immutable prototype history without storing a build, manifest, or upload. +Artifact payload validation and storage are owned by the separate [artifact ingestion layer](artifact-ingestion.md). User authorization controls who can provision the workload boundary and read versions; source provenance does not affect either decision. ## Roles -| Role | Product authority | -| --- | --- | -| `viewer` | Read the authorized workspace, prototype, and versions. | -| `commenter` | Includes viewer authority and reserves the role for future review comments. | -| `editor` | Includes commenter authority, creates prototypes within an authorized workspace, creates versions, and archives prototypes. | -| `administrator` | Includes editor authority and changes direct access rules. | +| Role | Product authority | +| --------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `viewer` | Read the authorized workspace, prototype, and versions. | +| `commenter` | Includes viewer authority and reserves the role for future review comments. | +| `editor` | Includes commenter authority, creates prototypes within an authorized workspace, and archives prototypes. | +| `administrator` | Includes editor authority and changes direct access rules. | Installation membership and installation administrator status do not grant access to existing workspaces or prototypes. The workspace-creation rule is the only installation-administrator product rule in this layer. After creation, the user's explicit workspace grant supplies authority. @@ -73,8 +74,9 @@ The product API uses: - `PUT /api/prototypes/:prototypeId/authorization` - `GET /api/prototypes/:prototypeId/access/explanation` - `PUT` and `DELETE /api/prototypes/:prototypeId/access/:userId` -- `GET` and `POST /api/prototypes/:prototypeId/versions` +- `GET /api/prototypes/:prototypeId/versions` - `GET /api/prototypes/:prototypeId/versions/:versionId` +- `POST /api/prototypes/:prototypeId/publishers` List routes use an opaque UUID cursor and a limit from 1 through 100. Prototype and version cursors belong to their owning workspace or prototype; a cursor from another scope is invalid. Invalid pagination returns `422 invalid_pagination`, while malformed or missing path resources return their resource-specific `404` error. State-changing requests require the configured origin and matching CSRF cookie and header. Product responses are private and non-cacheable, request payloads are omitted from logs, and mutation routes use a bounded rate limit. @@ -82,7 +84,7 @@ Every product route restores the current session and active installation members ## Audits -Workspace creation, prototype creation and archival, version creation, role grants, explicit denies, and rule removal write structured security audits in the same transaction as the product change. A failed audit rolls back the product change. +Workspace creation, prototype creation and archival, workload publisher provisioning, role grants, explicit denies, and rule removal write structured security audits in the same transaction as the product change. Artifact publication writes its workload audit in the publication transaction. A failed audit rolls back the product change. Records identify the actor, session, authentication-provider revision, affected product records, target user when applicable, outcome, and a bounded reason. They do not include cookies, provider tokens, host secrets, request bodies, prototype artifacts, or mutable identity labels. @@ -92,4 +94,4 @@ The [GitHub integration](github-integration.md) confirms permissions with instal Repository rename changes mutable owner, name, and default-branch fields without changing the immutable source identity. Repository removal, installation removal, and permission loss update synchronized baselines only. Direct Hub overrides remain intact. -Source mutation, public links, artifact ingestion, promotion, retention, and rollback remain separate product boundaries. +Source mutation and public links remain separate product boundaries. Artifact ingestion, promotion, retention, and rollback follow the provider-neutral contract without changing user access rules. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index f0ca5959..9253a6a1 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -55,17 +55,19 @@ Start the PostgreSQL service as described in [Development](development.md) and s 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. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. -Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, workload credential revocation, publisher suspension, archive lifecycle, and explainable decisions. Artifact publishing cases cover manifest bounds, credential-free provenance URLs, digest and size verification, scoped workload actions, token non-storage, idempotent replay and conflicts, concurrent sequence assignment, immutable rows, atomic promotion and rollback, revocation and disable races, cross-prototype isolation, audit rollback, body limits, request-log omission, and the local non-GitHub producer contract. Local storage cases reject symbolic-link roots, shards, and artifact paths. GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover transactional cache revalidation, invitation precedence during source outage, immutable-ID rename and 404 invalidation, token-404 installation removal, credential-independent webhook revocation, retryable source-generation changes, per-result confirmation ordering, active-identity filtering, bounded provider requests and apply batches, partial scans, dirty-event retry coalescing, first-enrollment and admission-failure auditing, repository and installation lifecycle, provider-response versus apply failures, scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, manual suspension and audited administration preservation, compatibility membership isolation, synchronized session freshness, and rollback. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. +The reference artifact adapter stores content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`. Give the test process a temporary absolute directory. Production deployments must mount durable storage and monitor capacity; the reference adapter has no replication or automatic garbage collection. Before deleting an unreferenced file, confirm that no `PrototypeVersion.storageKey` points to it and that no publish transaction or backup can still require it. + 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. 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`, then copies the template for every worker slot. +1. Before workers start, setup creates a template database with a unique name for each project and run. It applies the current Prisma schema with `prisma db push`, then copies the template for every worker slot. Tests that exercise a migration-only database invariant install that invariant in their isolated worker database. 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 tables across user schemas and resets generated identity counters. It preserves Prisma's migration records. Each case creates its own records; there is no shared seed data. 4. After the run, teardown drops only the databases it created, even if tests left connections open. diff --git a/hub/package.json b/hub/package.json index f6abbd26..fa383979 100644 --- a/hub/package.json +++ b/hub/package.json @@ -39,6 +39,7 @@ "dev:api": "node --conditions=development --watch server/index.ts", "start": "node dist/server/index.js", "preview": "vite preview --host 127.0.0.1", + "publish:artifact": "node script/publish-artifact.mjs", "extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.mjs", "extension:build": "npm run extension:modules:build && vite build --config vite.prototype-canvas.config.mjs && node script/build-prototype-preview-bridge.mjs", "extension:modules:build": "node script/build-extension-modules.mjs", diff --git a/hub/schema/artifact-publication.prisma b/hub/schema/artifact-publication.prisma new file mode 100644 index 00000000..9b42b51f --- /dev/null +++ b/hub/schema/artifact-publication.prisma @@ -0,0 +1,24 @@ +model ArtifactPublication { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + workloadPublisherId String @map("workload_publisher_id") @db.Uuid + prototypeVersionId String @unique @map("prototype_version_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + workloadPublisher WorkloadPublisher @relation(fields: [workloadPublisherId], references: [id], onDelete: Restrict) + prototypeVersion PrototypeVersion @relation(fields: [prototypeVersionId], references: [id], onDelete: Restrict) + + // Attribute columns + idempotencyKeyDigest String @map("idempotency_key_digest") + requestDigest String @map("request_digest") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([workloadPublisherId, idempotencyKeyDigest]) + @@index([prototypeId]) + @@map("artifact_publications") +} diff --git a/hub/schema/migrations/20261027230000_artifact_ingestion/migration.sql b/hub/schema/migrations/20261027230000_artifact_ingestion/migration.sql new file mode 100644 index 00000000..baf29a19 --- /dev/null +++ b/hub/schema/migrations/20261027230000_artifact_ingestion/migration.sql @@ -0,0 +1,125 @@ +-- CreateTable +CREATE TABLE "workload_publishers" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "actions" TEXT[] NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workload_publishers_pkey" PRIMARY KEY ("id"), + CONSTRAINT "workload_publishers_actions" CHECK ( + cardinality("actions") BETWEEN 1 AND 2 AND + "actions" <@ ARRAY['publish', 'read-status']::TEXT[] AND + "actions" <> ARRAY['publish', 'publish']::TEXT[] AND + "actions" <> ARRAY['read-status', 'read-status']::TEXT[] + ) +); + +-- CreateTable +CREATE TABLE "workload_credentials" ( + "id" UUID NOT NULL, + "workload_publisher_id" UUID NOT NULL, + "token_digest" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workload_credentials_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "prototype_versions" +ALTER COLUMN "created_by_user_id" DROP NOT NULL, +ADD COLUMN "workload_publisher_id" UUID, +ADD COLUMN "artifact_format" TEXT, +ADD COLUMN "artifact_version" TEXT, +ADD COLUMN "content_digest" TEXT, +ADD COLUMN "size_bytes" INTEGER, +ADD COLUMN "built_at" TIMESTAMPTZ(3), +ADD COLUMN "provenance_provider" TEXT, +ADD COLUMN "provenance_source_url" TEXT, +ADD COLUMN "provenance_revision" TEXT, +ADD COLUMN "provenance_ref" TEXT, +ADD COLUMN "provenance_pipeline" TEXT, +ADD COLUMN "provenance_run_id" TEXT, +ADD COLUMN "storage_key" TEXT, +ADD CONSTRAINT "prototype_versions_single_creator" CHECK ( + ("created_by_user_id" IS NOT NULL) <> ("workload_publisher_id" IS NOT NULL) +), +ADD CONSTRAINT "prototype_versions_published_artifact" CHECK ( + "workload_publisher_id" IS NULL OR ( + "artifact_format" IS NOT NULL AND + "artifact_version" IS NOT NULL AND + "content_digest" IS NOT NULL AND + "size_bytes" IS NOT NULL AND + "built_at" IS NOT NULL AND + "provenance_provider" IS NOT NULL AND + "storage_key" IS NOT NULL + ) +); + +-- CreateTable +CREATE TABLE "artifact_publications" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "workload_publisher_id" UUID NOT NULL, + "prototype_version_id" UUID NOT NULL, + "idempotency_key_digest" TEXT NOT NULL, + "request_digest" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "artifact_publications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "prototype_channels" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "prototype_version_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "prototype_channels_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "security_audits" ADD COLUMN "workload_publisher_id" UUID; + +-- CreateIndex +CREATE INDEX "workload_publishers_prototype_id_idx" ON "workload_publishers"("prototype_id"); +CREATE UNIQUE INDEX "workload_credentials_token_digest_key" ON "workload_credentials"("token_digest"); +CREATE INDEX "workload_credentials_workload_publisher_id_idx" ON "workload_credentials"("workload_publisher_id"); +CREATE INDEX "prototype_versions_workload_publisher_id_idx" ON "prototype_versions"("workload_publisher_id"); +CREATE UNIQUE INDEX "prototype_versions_prototype_id_content_digest_key" ON "prototype_versions"("prototype_id", "content_digest"); +CREATE UNIQUE INDEX "artifact_publications_prototype_version_id_key" ON "artifact_publications"("prototype_version_id"); +CREATE UNIQUE INDEX "artifact_publications_workload_publisher_id_idempotency_key_digest_key" ON "artifact_publications"("workload_publisher_id", "idempotency_key_digest"); +CREATE INDEX "artifact_publications_prototype_id_idx" ON "artifact_publications"("prototype_id"); +CREATE UNIQUE INDEX "prototype_channels_prototype_id_name_key" ON "prototype_channels"("prototype_id", "name"); +CREATE INDEX "prototype_channels_prototype_version_id_idx" ON "prototype_channels"("prototype_version_id"); +CREATE INDEX "security_audits_workload_publisher_id_idx" ON "security_audits"("workload_publisher_id"); + +-- AddForeignKey +ALTER TABLE "workload_publishers" ADD CONSTRAINT "workload_publishers_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "workload_credentials" ADD CONSTRAINT "workload_credentials_workload_publisher_id_fkey" FOREIGN KEY ("workload_publisher_id") REFERENCES "workload_publishers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_versions" ADD CONSTRAINT "prototype_versions_workload_publisher_id_fkey" FOREIGN KEY ("workload_publisher_id") REFERENCES "workload_publishers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "artifact_publications" ADD CONSTRAINT "artifact_publications_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "artifact_publications" ADD CONSTRAINT "artifact_publications_workload_publisher_id_fkey" FOREIGN KEY ("workload_publisher_id") REFERENCES "workload_publishers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "artifact_publications" ADD CONSTRAINT "artifact_publications_prototype_version_id_fkey" FOREIGN KEY ("prototype_version_id") REFERENCES "prototype_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_channels" ADD CONSTRAINT "prototype_channels_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_channels" ADD CONSTRAINT "prototype_channels_prototype_version_id_fkey" FOREIGN KEY ("prototype_version_id") REFERENCES "prototype_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Immutable prototype versions may be inserted and read, but never rewritten or deleted. +CREATE FUNCTION reject_prototype_version_mutation() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'prototype versions are immutable'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER prototype_versions_immutable +BEFORE UPDATE OR DELETE ON "prototype_versions" +FOR EACH ROW EXECUTE FUNCTION reject_prototype_version_mutation(); diff --git a/hub/schema/prototype-channel.prisma b/hub/schema/prototype-channel.prisma new file mode 100644 index 00000000..b650e614 --- /dev/null +++ b/hub/schema/prototype-channel.prisma @@ -0,0 +1,21 @@ +model PrototypeChannel { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + prototypeVersionId String @map("prototype_version_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + prototypeVersion PrototypeVersion @relation(fields: [prototypeVersionId], references: [id], onDelete: Restrict) + + // Attribute columns + name 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([prototypeId, name]) + @@index([prototypeVersionId]) + @@map("prototype_channels") +} diff --git a/hub/schema/prototype-version.prisma b/hub/schema/prototype-version.prisma index 8a60193d..4210e28c 100644 --- a/hub/schema/prototype-version.prisma +++ b/hub/schema/prototype-version.prisma @@ -1,22 +1,40 @@ model PrototypeVersion { // ID columns - id String @id @default(uuid()) @db.Uuid - prototypeId String @map("prototype_id") @db.Uuid - createdByUserId String @map("created_by_user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + createdByUserId String? @map("created_by_user_id") @db.Uuid + workloadPublisherId String? @map("workload_publisher_id") @db.Uuid // Relations - prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) - createdBy User @relation("PrototypeVersionCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + createdBy User? @relation("PrototypeVersionCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + workloadPublisher WorkloadPublisher? @relation(fields: [workloadPublisherId], references: [id], onDelete: Restrict) + publication ArtifactPublication? + channels PrototypeChannel[] // Attribute columns - sequence Int - label String? + sequence Int + label String? + artifactFormat String? @map("artifact_format") + artifactVersion String? @map("artifact_version") + contentDigest String? @map("content_digest") + sizeBytes Int? @map("size_bytes") + builtAt DateTime? @map("built_at") @db.Timestamptz(3) + provenanceProvider String? @map("provenance_provider") + provenanceSourceUrl String? @map("provenance_source_url") + provenanceRevision String? @map("provenance_revision") + provenanceRef String? @map("provenance_ref") + provenancePipeline String? @map("provenance_pipeline") + provenanceRunId String? @map("provenance_run_id") + storageKey String? @map("storage_key") // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) @@unique([prototypeId, sequence]) + @@unique([prototypeId, contentDigest]) @@index([createdByUserId]) + @@index([workloadPublisherId]) @@map("prototype_versions") } diff --git a/hub/schema/prototype.prisma b/hub/schema/prototype.prisma index 5064e50a..30dbb25a 100644 --- a/hub/schema/prototype.prisma +++ b/hub/schema/prototype.prisma @@ -5,11 +5,14 @@ model Prototype { createdByUserId String @map("created_by_user_id") @db.Uuid // Relations - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - createdBy User @relation("PrototypeCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) - versions PrototypeVersion[] - grants PrototypeAccessGrant[] + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("PrototypeCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + versions PrototypeVersion[] + grants PrototypeAccessGrant[] sourceRepository GitHubRepositorySource? @relation(fields: [sourceRepositoryId], references: [id], onDelete: SetNull) + publishers WorkloadPublisher[] + publications ArtifactPublication[] + channels PrototypeChannel[] // Attribute columns name String diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index ed0b61c4..cbc1f36a 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -9,6 +9,7 @@ model SecurityAudit { workspaceId String? @map("workspace_id") @db.Uuid prototypeId String? @map("prototype_id") @db.Uuid prototypeVersionId String? @map("prototype_version_id") @db.Uuid + workloadPublisherId String? @map("workload_publisher_id") @db.Uuid targetUserId String? @map("target_user_id") @db.Uuid githubRepositorySourceId String? @map("github_repository_source_id") @db.Uuid @@ -32,6 +33,7 @@ model SecurityAudit { @@index([workspaceId]) @@index([prototypeId]) @@index([prototypeVersionId]) + @@index([workloadPublisherId]) @@index([targetUserId]) @@index([githubRepositorySourceId]) @@map("security_audits") diff --git a/hub/schema/workload-credential.prisma b/hub/schema/workload-credential.prisma new file mode 100644 index 00000000..c043762b --- /dev/null +++ b/hub/schema/workload-credential.prisma @@ -0,0 +1,20 @@ +model WorkloadCredential { + // ID columns + id String @id @default(uuid()) @db.Uuid + workloadPublisherId String @map("workload_publisher_id") @db.Uuid + + // Relations + workloadPublisher WorkloadPublisher @relation(fields: [workloadPublisherId], references: [id], onDelete: Restrict) + + // Attribute columns + tokenDigest String @unique @map("token_digest") + expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_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([workloadPublisherId]) + @@map("workload_credentials") +} diff --git a/hub/schema/workload-publisher.prisma b/hub/schema/workload-publisher.prisma new file mode 100644 index 00000000..3ebebee7 --- /dev/null +++ b/hub/schema/workload-publisher.prisma @@ -0,0 +1,23 @@ +model WorkloadPublisher { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + credentials WorkloadCredential[] + versions PrototypeVersion[] + publications ArtifactPublication[] + + // Attribute columns + name String + actions String[] + active 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) + + @@index([prototypeId]) + @@map("workload_publishers") +} diff --git a/hub/script/publish-artifact.mjs b/hub/script/publish-artifact.mjs new file mode 100644 index 00000000..05d66d35 --- /dev/null +++ b/hub/script/publish-artifact.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +import { createHash, randomUUID } from "node:crypto" +import { readFile, stat } from "node:fs/promises" + +function usage() { + console.error( + "Usage: REPLICA_PUBLISH_TOKEN=... node script/publish-artifact.mjs HUB_URL PROTOTYPE_ID ARTIFACT_PATH [current|preview]", + ) +} + +const [, , hubUrl, prototypeId, artifactPath, promoteChannel] = process.argv +const token = process.env.REPLICA_PUBLISH_TOKEN +if ( + !hubUrl || + !prototypeId || + !artifactPath || + !token || + (promoteChannel && !["current", "preview"].includes(promoteChannel)) +) { + usage() + process.exitCode = 2 +} else { + const bytes = await readFile(artifactPath) + const details = await stat(artifactPath) + const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}` + const response = await fetch( + new URL(`/api/publishing/prototypes/${prototypeId}/versions`, hubUrl), + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "idempotency-key": process.env.REPLICA_IDEMPOTENCY_KEY ?? randomUUID(), + }, + body: JSON.stringify({ + manifest: { + schemaVersion: 1, + prototypeId, + artifact: { + format: process.env.REPLICA_ARTIFACT_FORMAT ?? "replica.prototype.bundle", + version: process.env.REPLICA_ARTIFACT_VERSION ?? "1", + digest, + sizeBytes: bytes.length, + builtAt: details.mtime.toISOString(), + }, + provenance: { + provider: process.env.REPLICA_PROVENANCE_PROVIDER ?? "local-cli", + ...(process.env.REPLICA_SOURCE_URL + ? { sourceUrl: process.env.REPLICA_SOURCE_URL } + : {}), + ...(process.env.REPLICA_REVISION ? { revision: process.env.REPLICA_REVISION } : {}), + ...(process.env.REPLICA_REF ? { ref: process.env.REPLICA_REF } : {}), + ...(process.env.REPLICA_PIPELINE ? { pipeline: process.env.REPLICA_PIPELINE } : {}), + ...(process.env.REPLICA_RUN_ID ? { runId: process.env.REPLICA_RUN_ID } : {}), + }, + }, + artifactBase64: bytes.toString("base64"), + ...(promoteChannel ? { promoteChannel } : {}), + }), + }, + ) + const result = await response.text() + if (!response.ok) { + console.error(`Publish failed (${response.status}): ${result}`) + process.exitCode = 1 + } else { + process.stdout.write(`${result}\n`) + } +} diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 22872cd7..947ade16 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -7,6 +7,7 @@ import { createRequestLogger } from "../integrations/pino/create-request-logger/ import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" import { routes } from "../routes/index.ts" import type { AuthenticationRoutes } from "../routes/types.ts" +import { maximumPublishingJsonBytes } from "../domains/artifact-publishing/create-service/constants.ts" /** Give every endpoint the same request logging and safe error responses. */ export function createApp({ @@ -25,10 +26,12 @@ export function createApp({ "/api/github/webhooks", "/api/workspaces", "/api/prototypes", + "/api/publishing", ], omitRequestLogPayload, ) app.use("/api/github/webhooks", express.raw({ type: "application/json", limit: "1mb" })) + app.use("/api/publishing", express.json({ limit: maximumPublishingJsonBytes })) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/domains/artifact-publishing/create-service/constants.ts b/hub/server/domains/artifact-publishing/create-service/constants.ts new file mode 100644 index 00000000..ec9f2a16 --- /dev/null +++ b/hub/server/domains/artifact-publishing/create-service/constants.ts @@ -0,0 +1,2 @@ +export const maximumArtifactBytes = 8 * 1024 * 1024 +export const maximumPublishingJsonBytes = 12 * 1024 * 1024 diff --git a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts new file mode 100644 index 00000000..0c6103b9 --- /dev/null +++ b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts @@ -0,0 +1,595 @@ +import { createHash, randomUUID } from "node:crypto" + +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createArtifactPublishingService } from "#server/domains/artifact-publishing/create-service" +import type { ArtifactManifest } from "#server/domains/artifact-publishing/create-service/types" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import type { ArtifactStorage } from "#server/integrations/artifact-storage/types" +import { digestSecret } from "#server/utils/digest-secret" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +function artifact(content: string) { + const bytes = Buffer.from(content) + return { + bytes, + base64: bytes.toString("base64"), + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + } +} + +function manifest( + prototypeId: string, + publishedArtifact: ReturnType, + overrides: Partial = {}, +): ArtifactManifest { + return { + schemaVersion: 1, + prototypeId, + artifact: { + format: "replica-build", + version: "1", + digest: publishedArtifact.digest, + sizeBytes: publishedArtifact.bytes.length, + builtAt: "2026-10-27T20:00:00.000Z", + }, + provenance: { + provider: "build-system", + sourceUrl: "https://ci.example/runs/123", + revision: "abc123", + ref: "refs/heads/main", + pipeline: "publish", + runId: "123", + }, + ...overrides, + } +} + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + + const stored = new Map() + const storage: ArtifactStorage = { + put(digest, bytes) { + const key = digest.replace(":", "/") + const created = !stored.has(key) + if (created) stored.set(key, bytes.slice()) + return Promise.resolve({ key, created }) + }, + read(key) { + const bytes = stored.get(key) + return bytes + ? Promise.resolve(bytes.slice()) + : Promise.reject(new Error("Artifact not found")) + }, + remove(key) { + stored.delete(key) + return Promise.resolve() + }, + } + + return { + prisma, + storage, + service: createArtifactPublishingService({ database: prisma, storage }), + } +} + +async function createPrototype(prisma: PrismaClient) { + const user = await prisma.user.create({ data: { displayName: "Publisher owner" } }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: user.id, + }, + }) + return { prototype, user, workspace } +} + +async function createAdministratorSession(prisma: PrismaClient, token: string) { + const store = createIdentityStore(prisma) + const authorizedAt = new Date() + const actor = await store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Prototype administrator", + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject: "publisher-administrator", + username: "publisher-administrator", + displayName: user.displayName, + avatarUrl: null, + }) + const provider = await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }) + await records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source: "manual", + }) + await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }) + return user + }) + return { actor, store } +} + +async function createCredential( + prisma: PrismaClient, + prototypeId: string, + actions: ("publish" | "read-status")[] = ["publish", "read-status"], +) { + const credentialId = randomUUID() + const token = `rpl_pub_${credentialId}.${"t".repeat(43)}` + const publisher = await prisma.workloadPublisher.create({ + data: { + prototypeId, + name: "CI publisher", + actions, + credentials: { + create: { + id: credentialId, + tokenDigest: digestSecret(token), + }, + }, + }, + }) + return { + authorization: `Bearer ${token}`, + credentialId, + publisher, + token, + } +} + +function publicationInput( + prototypeId: string, + publishedArtifact: ReturnType, + overrides: Record = {}, +) { + return { + manifest: manifest(prototypeId, publishedArtifact), + artifactBase64: publishedArtifact.base64, + ...overrides, + } +} + +function publishedVersion(result: { version: unknown }) { + if ( + result.version === null || + typeof result.version !== "object" || + !("id" in result.version) || + typeof result.version.id !== "string" + ) { + throw new Error("Expected a published version") + } + return { id: result.version.id } +} + +describe("createArtifactPublishingService() database behavior", () => { + it.each([ + { + name: "unknown manifest field", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { ...valid.manifest, unrecognized: true }, + }), + }, + { + name: "unsupported schema version", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { ...valid.manifest, schemaVersion: 2 }, + }), + }, + { + name: "non-HTTP provenance URL", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { ...valid.manifest.provenance, sourceUrl: "file:///private/build" }, + }, + }), + }, + { + name: "different prototype identifier", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { ...valid.manifest, prototypeId: randomUUID() }, + }), + }, + ])("rejects a manifest with $name before creating a version", async ({ change }) => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifact("manifest-validation") + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "manifest-case", + change(publicationInput(prototype.id, publishedArtifact)), + ), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_artifact_manifest", + }) + expect(await context.prisma.prototypeVersion.count()).toBe(0) + }) + + it("rejects an artifact whose decoded size differs from the manifest", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifact("size-mismatch") + const input = publicationInput(prototype.id, publishedArtifact) + input.manifest.artifact.sizeBytes += 1 + + await expect( + context.service.publish(credential.authorization, prototype.id, "size-case", input), + ).rejects.toMatchObject({ + status: 422, + code: "artifact_size_mismatch", + }) + expect(await context.prisma.prototypeVersion.count()).toBe(0) + }) + + it("rejects an artifact whose content digest differs from the manifest", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifact("digest-mismatch") + const input = publicationInput(prototype.id, publishedArtifact) + input.manifest.artifact.digest = artifact("different-content").digest + + await expect( + context.service.publish(credential.authorization, prototype.id, "digest-case", input), + ).rejects.toMatchObject({ + status: 422, + code: "artifact_digest_mismatch", + }) + expect(await context.prisma.prototypeVersion.count()).toBe(0) + }) + + it("stores only a digest of a prototype-scoped workload token", async () => { + const context = await database() + const sessionToken = "publisher-administrator-session" + const { actor, store } = await createAdministratorSession(context.prisma, sessionToken) + const workspace = await context.prisma.workspace.create({ + data: { + name: "Credential workspace", + createdByUserId: actor.id, + grants: { + create: { + userId: actor.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await context.prisma.prototype.create({ + data: { + name: "Credential prototype", + workspaceId: workspace.id, + createdByUserId: actor.id, + }, + }) + const authorization = createPrototypeAuthorizationService({ + database: context.prisma, + store, + }) + const provisioned = await authorization.createPublisherCredential(sessionToken, prototype.id, { + name: "CI publisher", + actions: ["publish", "read-status"], + }) + if ( + provisioned === null || + typeof provisioned !== "object" || + !("credential" in provisioned) || + provisioned.credential === null || + typeof provisioned.credential !== "object" || + !("id" in provisioned.credential) || + typeof provisioned.credential.id !== "string" || + !("token" in provisioned.credential) || + typeof provisioned.credential.token !== "string" + ) { + throw new Error("Expected a provisioned workload credential") + } + + const saved = await context.prisma.workloadCredential.findUniqueOrThrow({ + where: { id: provisioned.credential.id }, + }) + + expect(saved.tokenDigest).toBe(digestSecret(provisioned.credential.token)) + expect(saved.tokenDigest).not.toContain(provisioned.credential.token) + expect(JSON.stringify(saved)).not.toContain(provisioned.credential.token) + }) + + it("enforces publish and read-status action scopes independently", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const publishOnly = await createCredential(context.prisma, prototype.id, ["publish"]) + const statusOnly = await createCredential(context.prisma, prototype.id, ["read-status"]) + const publishedArtifact = artifact("action-scope") + + await context.service.publish( + publishOnly.authorization, + prototype.id, + "action-scope", + publicationInput(prototype.id, publishedArtifact), + ) + + await expect( + context.service.getStatus(publishOnly.authorization, prototype.id, "action-scope"), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + await expect( + context.service.publish( + statusOnly.authorization, + prototype.id, + "status-cannot-publish", + publicationInput(prototype.id, artifact("status-only")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + }) + + it("rejects a credential issued for a different prototype", async () => { + const context = await database() + const first = await createPrototype(context.prisma) + const second = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, first.prototype.id) + + await expect( + context.service.publish( + credential.authorization, + second.prototype.id, + "cross-prototype", + publicationInput(second.prototype.id, artifact("isolated")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + expect( + await context.prisma.prototypeVersion.count({ + where: { prototypeId: second.prototype.id }, + }), + ).toBe(0) + }) + + it("returns the same immutable version for an idempotent retry", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const input = publicationInput(prototype.id, artifact("idempotent")) + + const first = await context.service.publish( + credential.authorization, + prototype.id, + "same-request", + input, + ) + const replay = await context.service.publish( + credential.authorization, + prototype.id, + "same-request", + input, + ) + + expect(first).toMatchObject({ replayed: false }) + expect(replay).toMatchObject({ replayed: true, version: publishedVersion(first) }) + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(1) + expect( + await context.prisma.artifactPublication.count({ + where: { workloadPublisherId: credential.publisher.id }, + }), + ).toBe(1) + }) + + it("rejects reuse of an idempotency key for a different publication", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifact("idempotency-conflict") + const first = publicationInput(prototype.id, publishedArtifact) + const conflicting = publicationInput(prototype.id, publishedArtifact) + conflicting.manifest.provenance.runId = "different-run" + + await context.service.publish(credential.authorization, prototype.id, "reused-key", first) + await expect( + context.service.publish(credential.authorization, prototype.id, "reused-key", conflicting), + ).rejects.toMatchObject({ status: 409, code: "idempotency_conflict" }) + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(1) + }) + + it("serializes concurrent publications into ordered immutable versions", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + + const results = await Promise.all([ + context.service.publish( + credential.authorization, + prototype.id, + "concurrent-one", + publicationInput(prototype.id, artifact("concurrent-one")), + ), + context.service.publish( + credential.authorization, + prototype.id, + "concurrent-two", + publicationInput(prototype.id, artifact("concurrent-two")), + ), + ]) + const versions = await context.prisma.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { sequence: "asc" }, + }) + + expect(results.map(({ replayed }) => replayed)).toEqual([false, false]) + expect(versions.map(({ sequence }) => sequence)).toEqual([1, 2]) + await context.prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_test_prototype_version_mutation() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'prototype versions are immutable'; + END; + $$ LANGUAGE plpgsql; + `) + await context.prisma.$executeRawUnsafe(` + CREATE TRIGGER test_prototype_versions_immutable + BEFORE UPDATE OR DELETE ON prototype_versions + FOR EACH ROW EXECUTE FUNCTION reject_test_prototype_version_mutation(); + `) + try { + await expect( + context.prisma.prototypeVersion.update({ + where: { id: versions[0]!.id }, + data: { label: "rewritten" }, + }), + ).rejects.toThrow(/prototype versions are immutable/i) + await expect( + context.prisma.prototypeVersion.delete({ where: { id: versions[1]!.id } }), + ).rejects.toThrow(/prototype versions are immutable/i) + } finally { + await context.prisma.$executeRawUnsafe( + "DROP TRIGGER test_prototype_versions_immutable ON prototype_versions", + ) + await context.prisma.$executeRawUnsafe( + "DROP FUNCTION reject_test_prototype_version_mutation()", + ) + } + }) + + it("promotes and rolls back a channel by moving one atomic pointer", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const first = await context.service.publish( + credential.authorization, + prototype.id, + "channel-one", + publicationInput(prototype.id, artifact("channel-one")), + ) + const second = await context.service.publish( + credential.authorization, + prototype.id, + "channel-two", + publicationInput(prototype.id, artifact("channel-two")), + ) + const firstVersion = publishedVersion(first) + const secondVersion = publishedVersion(second) + + await context.service.promote(credential.authorization, prototype.id, "current", { + versionId: firstVersion.id, + }) + await context.service.promote(credential.authorization, prototype.id, "current", { + versionId: secondVersion.id, + }) + await context.service.promote(credential.authorization, prototype.id, "preview", { + versionId: secondVersion.id, + }) + await context.service.promote(credential.authorization, prototype.id, "current", { + versionId: firstVersion.id, + }) + + expect( + await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { name: "asc" }, + select: { name: true, prototypeVersionId: true }, + }), + ).toEqual([ + { name: "current", prototypeVersionId: firstVersion.id }, + { name: "preview", prototypeVersionId: secondVersion.id }, + ]) + expect( + await context.prisma.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { sequence: "asc" }, + select: { id: true, sequence: true }, + }), + ).toEqual([ + { id: firstVersion.id, sequence: 1 }, + { id: secondVersion.id, sequence: 2 }, + ]) + }) + + it("rolls back a published version when its audit cannot be saved", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + + await context.prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_version_publish_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = 'prototype.version.publish' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await context.prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_version_publish_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_version_publish_audit(); + `) + try { + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "audit-failure", + publicationInput(prototype.id, artifact("audit-failure")), + ), + ).rejects.toThrow("audit unavailable") + } finally { + await context.prisma.$executeRawUnsafe( + "DROP TRIGGER reject_version_publish_audit ON security_audits", + ) + await context.prisma.$executeRawUnsafe("DROP FUNCTION reject_version_publish_audit()") + } + + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.artifactPublication.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + }), + ).toBe(0) + }) +}) diff --git a/hub/server/domains/artifact-publishing/create-service/index.ts b/hub/server/domains/artifact-publishing/create-service/index.ts new file mode 100644 index 00000000..e6316bb3 --- /dev/null +++ b/hub/server/domains/artifact-publishing/create-service/index.ts @@ -0,0 +1,375 @@ +import { createHash } from "node:crypto" + +import { z } from "zod" + +import type { ArtifactStorage } from "#server/integrations/artifact-storage/types" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" +import { AuthenticationError } from "#server/utils/authentication-error" + +import { maximumArtifactBytes } from "./constants.ts" +import type { ArtifactPublishingService, PrototypeChannelName, PublisherAction } from "./types.ts" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +const boundedIdentifier = z.string().trim().min(1).max(255) +const manifestSchema = z + .object({ + schemaVersion: z.literal(1), + prototypeId: z.uuid(), + artifact: z.object({ + format: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), + version: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/), + digest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + sizeBytes: z.number().int().min(0).max(maximumArtifactBytes), + builtAt: z.iso.datetime({ offset: true }), + }), + provenance: z.object({ + provider: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), + sourceUrl: z + .url() + .max(2_048) + .refine((value) => ["http:", "https:"].includes(new URL(value).protocol)) + .optional(), + revision: boundedIdentifier.optional(), + ref: boundedIdentifier.optional(), + pipeline: boundedIdentifier.optional(), + runId: boundedIdentifier.optional(), + }), + }) + .strict() +const publishSchema = z + .object({ + manifest: manifestSchema, + artifactBase64: z.string().max(Math.ceil((maximumArtifactBytes * 4) / 3) + 4), + promoteChannel: z.enum(["current", "preview"]).optional(), + }) + .strict() +const promotionSchema = z.object({ versionId: z.uuid() }).strict() +const channelSchema = z.enum(["current", "preview"]) +const prototypeIdSchema = z.uuid() +const idempotencyKeySchema = z + .string() + .min(1) + .max(200) + .regex(/^[\x21-\x7e]+$/) +const authorizationSchema = /^Bearer (rpl_pub_([0-9a-f-]{36})\.([A-Za-z0-9_-]{43}))$/ + +function error(status: number, code: string, message: string): never { + throw new AuthenticationError(status, code, message) +} + +function parsePrototypeId(prototypeId: string): void { + if (!prototypeIdSchema.safeParse(prototypeId).success) { + error(404, "prototype_not_found", "The prototype was not found.") + } +} + +function parseIdempotencyKey(value: string): string { + const parsed = idempotencyKeySchema.safeParse(value) + if (!parsed.success) { + error(422, "invalid_idempotency_key", "The idempotency key is invalid.") + } + return parsed.data +} + +function parseArtifactBase64(value: string): Uint8Array { + if ( + value.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + error(422, "invalid_artifact", "The artifact encoding is invalid.") + } + const bytes = Buffer.from(value, "base64") + if (bytes.length > maximumArtifactBytes || bytes.toString("base64") !== value) { + error(413, "artifact_too_large", "The artifact exceeds the maximum size.") + } + return bytes +} + +function sha256(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}` +} + +async function lockPrototype( + database: Prisma.TransactionClient, + prototypeId: string, +): Promise { + await database.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` +} + +function versionResponse(version: { + id: string + prototypeId: string + sequence: number + label: string | null + artifactFormat: string | null + artifactVersion: string | null + contentDigest: string | null + sizeBytes: number | null + builtAt: Date | null + provenanceProvider: string | null + provenanceSourceUrl: string | null + provenanceRevision: string | null + provenanceRef: string | null + provenancePipeline: string | null + provenanceRunId: string | null + createdAt: Date +}) { + return { + id: version.id, + prototypeId: version.prototypeId, + sequence: version.sequence, + label: version.label, + artifact: { + format: version.artifactFormat, + version: version.artifactVersion, + digest: version.contentDigest, + sizeBytes: version.sizeBytes, + builtAt: version.builtAt, + }, + provenance: { + provider: version.provenanceProvider, + sourceUrl: version.provenanceSourceUrl, + revision: version.provenanceRevision, + ref: version.provenanceRef, + pipeline: version.provenancePipeline, + runId: version.provenanceRunId, + }, + createdAt: version.createdAt, + } +} + +export function createArtifactPublishingService({ + database, + storage, +}: { + database: PrismaClient + storage: ArtifactStorage +}): ArtifactPublishingService { + async function authenticate(authorization: string, prototypeId: string, action: PublisherAction) { + parsePrototypeId(prototypeId) + const match = authorizationSchema.exec(authorization) + if (!match?.[1] || !match[2]) { + error(401, "publisher_authentication_required", "Publisher authentication failed.") + } + const [, token, credentialId] = match + const credential = await database.workloadCredential.findUnique({ + where: { id: credentialId }, + include: { workloadPublisher: true }, + }) + if ( + !credential || + credential.revokedAt || + (credential.expiresAt && credential.expiresAt <= new Date()) || + !credential.workloadPublisher.active || + credential.workloadPublisher.prototypeId !== prototypeId || + !credential.workloadPublisher.actions.includes(action) || + !secretsMatch(credential.tokenDigest, digestSecret(token)) + ) { + error(403, "publisher_access_denied", "Publisher access is denied.") + } + return credential.workloadPublisher + } + + return { + async publish(authorization, prototypeId, idempotencyKey, input) { + const publisher = await authenticate(authorization, prototypeId, "publish") + const parsed = publishSchema.safeParse(input) + if (!parsed.success || parsed.data.manifest.prototypeId !== prototypeId) { + error(422, "invalid_artifact_manifest", "The artifact manifest is invalid.") + } + const keyDigest = digestSecret(parseIdempotencyKey(idempotencyKey)) + const bytes = parseArtifactBase64(parsed.data.artifactBase64) + if (bytes.length !== parsed.data.manifest.artifact.sizeBytes) { + error(422, "artifact_size_mismatch", "The artifact size does not match the manifest.") + } + if (sha256(bytes) !== parsed.data.manifest.artifact.digest) { + error(422, "artifact_digest_mismatch", "The artifact digest does not match the manifest.") + } + const requestDigest = digestSecret( + JSON.stringify({ + manifest: parsed.data.manifest, + promoteChannel: parsed.data.promoteChannel ?? null, + }), + ) + const stored = await storage.put(parsed.data.manifest.artifact.digest, bytes) + + return database.$transaction(async (transaction) => { + await lockPrototype(transaction, prototypeId) + const prototype = await transaction.prototype.findUnique({ where: { id: prototypeId } }) + if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") + if (prototype.archivedAt) { + error(409, "prototype_archived", "Archived prototypes cannot receive versions.") + } + const existing = await transaction.artifactPublication.findUnique({ + where: { + workloadPublisherId_idempotencyKeyDigest: { + workloadPublisherId: publisher.id, + idempotencyKeyDigest: keyDigest, + }, + }, + include: { prototypeVersion: true }, + }) + if (existing) { + if (existing.requestDigest !== requestDigest) { + error( + 409, + "idempotency_conflict", + "The idempotency key was already used for a different publication.", + ) + } + return { + version: versionResponse(existing.prototypeVersion), + replayed: true, + promotedChannel: parsed.data.promoteChannel ?? null, + } + } + const duplicate = await transaction.prototypeVersion.findFirst({ + where: { + prototypeId, + contentDigest: parsed.data.manifest.artifact.digest, + }, + }) + if (duplicate) { + error(409, "artifact_already_published", "This artifact was already published.") + } + const latest = await transaction.prototypeVersion.findFirst({ + where: { prototypeId }, + orderBy: { sequence: "desc" }, + }) + const manifest = parsed.data.manifest + const version = await transaction.prototypeVersion.create({ + data: { + prototypeId, + workloadPublisherId: publisher.id, + sequence: (latest?.sequence ?? 0) + 1, + artifactFormat: manifest.artifact.format, + artifactVersion: manifest.artifact.version, + contentDigest: manifest.artifact.digest, + sizeBytes: manifest.artifact.sizeBytes, + builtAt: new Date(manifest.artifact.builtAt), + provenanceProvider: manifest.provenance.provider, + provenanceSourceUrl: manifest.provenance.sourceUrl ?? null, + provenanceRevision: manifest.provenance.revision ?? null, + provenanceRef: manifest.provenance.ref ?? null, + provenancePipeline: manifest.provenance.pipeline ?? null, + provenanceRunId: manifest.provenance.runId ?? null, + storageKey: stored.key, + }, + }) + await transaction.artifactPublication.create({ + data: { + prototypeId, + workloadPublisherId: publisher.id, + prototypeVersionId: version.id, + idempotencyKeyDigest: keyDigest, + requestDigest, + }, + }) + if (parsed.data.promoteChannel) { + await transaction.prototypeChannel.upsert({ + where: { + prototypeId_name: { prototypeId, name: parsed.data.promoteChannel }, + }, + create: { + prototypeId, + prototypeVersionId: version.id, + name: parsed.data.promoteChannel, + }, + update: { prototypeVersionId: version.id }, + }) + } + await saveSecurityAudit(transaction, { + event: "prototype.version.publish", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + workspaceId: prototype.workspaceId, + prototypeId, + prototypeVersionId: version.id, + workloadPublisherId: publisher.id, + context: { + source: "workload_publisher", + ...(parsed.data.promoteChannel + ? { reason: `channel_${parsed.data.promoteChannel}` } + : {}), + }, + }) + return { + version: versionResponse(version), + replayed: false, + promotedChannel: parsed.data.promoteChannel ?? null, + } + }) + }, + + async getStatus(authorization, prototypeId, idempotencyKey) { + const publisher = await authenticate(authorization, prototypeId, "read-status") + const publication = await database.artifactPublication.findUnique({ + where: { + workloadPublisherId_idempotencyKeyDigest: { + workloadPublisherId: publisher.id, + idempotencyKeyDigest: digestSecret(parseIdempotencyKey(idempotencyKey)), + }, + }, + include: { prototypeVersion: true }, + }) + if (!publication || publication.prototypeId !== prototypeId) { + error(404, "publication_not_found", "The publication was not found.") + } + return { + publicationId: publication.id, + version: versionResponse(publication.prototypeVersion), + } + }, + + async promote(authorization, prototypeId, channel, input) { + const publisher = await authenticate(authorization, prototypeId, "publish") + const parsedChannel = channelSchema.safeParse(channel) + const parsed = promotionSchema.safeParse(input) + if (!parsedChannel.success || !parsed.success) { + error(422, "invalid_promotion", "The promotion is invalid.") + } + return database.$transaction(async (transaction) => { + await lockPrototype(transaction, prototypeId) + const prototype = await transaction.prototype.findUnique({ where: { id: prototypeId } }) + if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") + const version = await transaction.prototypeVersion.findFirst({ + where: { id: parsed.data.versionId, prototypeId }, + }) + if (!version) error(404, "version_not_found", "The version was not found.") + const pointer = await transaction.prototypeChannel.upsert({ + where: { + prototypeId_name: { + prototypeId, + name: parsedChannel.data satisfies PrototypeChannelName, + }, + }, + create: { + prototypeId, + prototypeVersionId: version.id, + name: parsedChannel.data, + }, + update: { prototypeVersionId: version.id }, + }) + await saveSecurityAudit(transaction, { + event: "prototype.channel.promote", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + workspaceId: prototype.workspaceId, + prototypeId, + prototypeVersionId: version.id, + workloadPublisherId: publisher.id, + context: { + source: "workload_publisher", + reason: `channel_${parsedChannel.data}`, + }, + }) + return pointer + }) + }, + } +} diff --git a/hub/server/domains/artifact-publishing/create-service/types.ts b/hub/server/domains/artifact-publishing/create-service/types.ts new file mode 100644 index 00000000..54a12a56 --- /dev/null +++ b/hub/server/domains/artifact-publishing/create-service/types.ts @@ -0,0 +1,38 @@ +export type PublisherAction = "publish" | "read-status" +export type PrototypeChannelName = "current" | "preview" + +export interface ArtifactManifest { + schemaVersion: 1 + prototypeId: string + artifact: { + format: string + version: string + digest: string + sizeBytes: number + builtAt: string + } + provenance: { + provider: string + sourceUrl?: string + revision?: string + ref?: string + pipeline?: string + runId?: string + } +} + +export interface ArtifactPublishingService { + publish( + authorization: string, + prototypeId: string, + idempotencyKey: string, + input: unknown, + ): Promise<{ version: unknown; replayed: boolean; promotedChannel: string | null }> + getStatus(authorization: string, prototypeId: string, idempotencyKey: string): Promise + promote( + authorization: string, + prototypeId: string, + channel: string, + input: unknown, + ): Promise +} diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 8a1976ae..e9cf2a13 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -1,4 +1,7 @@ +import path from "node:path" + import { createAdmissionPolicy } from "#server/domains/authentication/create-admission-policy" +import { createArtifactPublishingService } from "#server/domains/artifact-publishing/create-service" import { createAdministrationService } from "#server/domains/administration/create-service" import { createAuthenticationService } from "#server/domains/authentication/create-service" import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" @@ -7,6 +10,7 @@ import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" +import { createLocalArtifactStorage } from "#server/integrations/artifact-storage/create-local-storage" import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" import { prisma } from "#server/integrations/prisma" @@ -88,8 +92,15 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv admissionPolicy: admissionPolicy.name, trustedProxyHops: readTrustedProxyHops(environment), } + const artifactStoragePath = path.resolve( + environment.HUB_ARTIFACT_STORAGE_PATH ?? path.join(process.cwd(), ".data", "artifacts"), + ) return { administration: createAdministrationService({ store }), + artifactPublishing: createArtifactPublishingService({ + database: prisma, + storage: createLocalArtifactStorage(artifactStoragePath), + }), githubAuthorization, prototypeAuthorization: createPrototypeAuthorizationService({ baseline: { diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index d688e6c6..df58414b 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -1369,59 +1369,6 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ).toEqual([{ event: "prototype.archive" }]) }) - it("does not commit a version queued behind archival", async () => { - const { prisma, service, store } = await database() - const actor = await createActor(store, { - subject: "archive-version-race", - token: "archive-version-race-token", - }) - const workspace = await prisma.workspace.create({ - data: { - name: "Workspace", - createdByUserId: actor.user.id, - grants: { - create: { - userId: actor.user.id, - effect: "allow", - role: "editor", - }, - }, - }, - }) - const prototype = await prisma.prototype.create({ - data: { - name: "Prototype", - workspaceId: workspace.id, - createdByUserId: actor.user.id, - }, - }) - const lock = await holdPrototypeLock(prisma, prototype.id) - - const archive = service.archivePrototype("archive-version-race-token", prototype.id) - await waitForBlockedRowLock(prisma, "prototypes") - const version = service.createVersion("archive-version-race-token", prototype.id, { - label: "Must not commit", - }) - lock.release() - await lock.holder - - const archived = (await archive) as { id: string; archivedAt: Date | null } - expect(archived.id).toBe(prototype.id) - expect(archived.archivedAt).toBeInstanceOf(Date) - await expect(version).rejects.toMatchObject({ - status: 409, - code: "prototype_archived", - }) - expect(await prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } })).toBe(0) - expect( - await prisma.securityAudit.findMany({ - where: { prototypeId: prototype.id }, - orderBy: { createdAt: "asc" }, - select: { event: true }, - }), - ).toEqual([{ event: "prototype.archive" }]) - }) - it("rolls back an access change when its audit cannot be saved", async () => { const { prisma, service, store } = await database() await createActor(store, { @@ -2423,7 +2370,6 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(second.nextCursor).toBeNull() expect(new Set([...first.items, ...second.items].map(({ id }) => id)).size).toBe(4) }) - it("rejects a prototype cursor from another workspace", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { @@ -2484,7 +2430,6 @@ describe("createPrototypeAuthorizationService() database behavior", () => { )) as { items: { id: string }[] } expect(page.items.map(({ id }) => id)).toEqual(firstWorkspacePrototypeIds) }) - it("lists immutable versions by sequence and paginates in that order", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { @@ -2560,7 +2505,6 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ]) expect(second.nextCursor).toBeNull() }) - it("rejects a version cursor from another prototype", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { @@ -2638,66 +2582,4 @@ describe("createPrototypeAuthorizationService() database behavior", () => { )) as { items: { id: string }[] } expect(page.items.map(({ id }) => id)).toEqual(firstPrototypeVersionIds) }) - - it("creates sequential immutable versions and rejects additions after archival", async () => { - const { prisma, service, store } = await database() - await createActor(store, { - subject: "versions", - token: "versions-token", - role: "administrator", - }) - const workspace = (await service.createWorkspace("versions-token", { - name: "Workspace", - })) as { id: string } - const prototype = (await service.createPrototype("versions-token", workspace.id, { - name: "Prototype", - })) as { id: string } - - const created = await Promise.all([ - service.createVersion("versions-token", prototype.id, { label: "First request" }), - service.createVersion("versions-token", prototype.id, { label: "Second request" }), - ]) - - expect( - created - .map((version) => (version as { sequence: number }).sequence) - .sort((left, right) => left - right), - ).toEqual([1, 2]) - expect( - await prisma.prototypeVersion.findMany({ - where: { prototypeId: prototype.id }, - orderBy: { sequence: "asc" }, - select: { sequence: true }, - }), - ).toEqual([{ sequence: 1 }, { sequence: 2 }]) - - await service.archivePrototype("versions-token", prototype.id) - await expect( - service.createVersion("versions-token", prototype.id, { label: "Too late" }), - ).rejects.toMatchObject({ - status: 409, - code: "prototype_archived", - }) - expect(await prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } })).toBe(2) - const audits = await prisma.securityAudit.findMany({ - where: { prototypeId: prototype.id }, - orderBy: { createdAt: "asc" }, - select: { - event: true, - context: true, - prototypeVersionId: true, - }, - }) - expect(audits.map(({ event }) => event)).toEqual([ - "prototype.create", - "prototype.version.create", - "prototype.version.create", - "prototype.archive", - ]) - expect(audits.every(({ context }) => JSON.stringify(context) === "{}")).toBe(true) - expect(audits[0]?.prototypeVersionId).toBeNull() - expect(audits[1]?.prototypeVersionId).toEqual(expect.any(String)) - expect(audits[2]?.prototypeVersionId).toEqual(expect.any(String)) - expect(audits[3]?.prototypeVersionId).toBeNull() - }) }) diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index a370576c..a91df7f1 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -1,3 +1,5 @@ +import { randomBytes, randomUUID } from "node:crypto" + import { z } from "zod" import { createIdentityRecords } from "#server/domains/identity/create-records" @@ -15,11 +17,22 @@ import type { import { getCurrentSession } from "#server/domains/authentication/get-current-session" import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" const nameSchema = z.object({ name: z.string().trim().min(1).max(120) }) -const versionSchema = z.object({ label: z.string().trim().min(1).max(120).nullable().optional() }) +const publisherSchema = z + .object({ + name: z.string().trim().min(1).max(120), + actions: z + .array(z.enum(["publish", "read-status"])) + .min(1) + .max(2) + .refine((actions) => new Set(actions).size === actions.length), + expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + }) + .strict() const pageSchema = z.object({ cursor: z.uuid().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), @@ -601,6 +614,52 @@ async function hasAnyPrototypeAccess({ ) } +function versionResult(version: { + id: string + prototypeId: string + sequence: number + label: string | null + artifactFormat: string | null + artifactVersion: string | null + contentDigest: string | null + sizeBytes: number | null + builtAt: Date | null + provenanceProvider: string | null + provenanceSourceUrl: string | null + provenanceRevision: string | null + provenanceRef: string | null + provenancePipeline: string | null + provenanceRunId: string | null + createdAt: Date +}) { + return { + id: version.id, + prototypeId: version.prototypeId, + sequence: version.sequence, + label: version.label, + artifact: version.contentDigest + ? { + format: version.artifactFormat, + version: version.artifactVersion, + digest: version.contentDigest, + sizeBytes: version.sizeBytes, + builtAt: version.builtAt, + } + : null, + provenance: version.provenanceProvider + ? { + provider: version.provenanceProvider, + sourceUrl: version.provenanceSourceUrl, + revision: version.provenanceRevision, + ref: version.provenanceRef, + pipeline: version.provenancePipeline, + runId: version.provenanceRunId, + } + : null, + createdAt: version.createdAt, + } +} + async function audit( database: Prisma.TransactionClient, actor: Awaited>, @@ -876,6 +935,69 @@ export function createPrototypeAuthorizationService({ } }, + async createPublisherCredential(sessionToken, prototypeId, input) { + const parsed = publisherSchema.safeParse(input) + if ( + !parsed.success || + (parsed.data.expiresAt !== null && + parsed.data.expiresAt !== undefined && + new Date(parsed.data.expiresAt) <= new Date()) + ) { + throw new AuthenticationError( + 422, + "invalid_workload_publisher", + "The workload publisher is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + baseline, + ) + requireDecision(decision, "administrator") + const credentialId = randomUUID() + const token = `rpl_pub_${credentialId}.${randomBytes(32).toString("base64url")}` + const publisher = await transaction.workloadPublisher.create({ + data: { + prototypeId, + name: parsed.data.name, + actions: parsed.data.actions, + credentials: { + create: { + id: credentialId, + tokenDigest: digestSecret(token), + expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : null, + }, + }, + }, + }) + await audit(transaction, actor, { + event: "prototype.publisher.create", + workspaceId: prototype.workspaceId, + prototypeId, + workloadPublisherId: publisher.id, + context: { source: "prototype_administrator" }, + }) + return { + publisher: { + id: publisher.id, + prototypeId: publisher.prototypeId, + name: publisher.name, + actions: publisher.actions, + active: publisher.active, + createdAt: publisher.createdAt, + }, + credential: { id: credentialId, token }, + } + }) + }, + async archivePrototype(sessionToken, prototypeId) { return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) @@ -1068,52 +1190,8 @@ export function createPrototypeAuthorizationService({ ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, }) - return pageResult(items, limit) - }, - - async createVersion(sessionToken, prototypeId, input) { - const parsed = versionSchema.safeParse(input) - if (!parsed.success) { - throw new AuthenticationError(422, "invalid_version", "The version is invalid.") - } - return store.lock(async (_records, transaction) => { - const actor = await currentSession(store, sessionToken, transaction) - requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") - await lockPrototype(transaction, prototypeId) - const { prototype, decision } = await prototypeDecision( - baseline, - transaction, - actor.userId, - prototypeId, - ) - requireDecision(decision, "editor") - if (prototype.archivedAt) { - throw new AuthenticationError( - 409, - "prototype_archived", - "Archived prototypes cannot receive versions.", - ) - } - const latest = await transaction.prototypeVersion.findFirst({ - where: { prototypeId }, - orderBy: { sequence: "desc" }, - }) - const version = await transaction.prototypeVersion.create({ - data: { - prototypeId, - createdByUserId: actor.userId, - sequence: (latest?.sequence ?? 0) + 1, - label: parsed.data.label ?? null, - }, - }) - await audit(transaction, actor, { - event: "prototype.version.create", - workspaceId: prototype.workspaceId, - prototypeId, - prototypeVersionId: version.id, - }) - return version - }) + const page = pageResult(items, limit) + return { ...page, items: page.items.map(versionResult) } }, async getVersion(sessionToken, prototypeId, versionId) { @@ -1127,7 +1205,7 @@ export function createPrototypeAuthorizationService({ if (!version) { throw new AuthenticationError(404, "version_not_found", "The version was not found.") } - return version + return versionResult(version) }, } } diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index ba9ad592..eec58cb6 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -24,6 +24,11 @@ export interface PrototypeAuthorizationService { listPrototypes(sessionToken: string, workspaceId: string, query: unknown): Promise> createPrototype(sessionToken: string, workspaceId: string, input: unknown): Promise getPrototype(sessionToken: string, prototypeId: string): Promise + createPublisherCredential( + sessionToken: string, + prototypeId: string, + input: unknown, + ): Promise archivePrototype(sessionToken: string, prototypeId: string): Promise setPrototypeAuthorization( sessionToken: string, @@ -43,7 +48,6 @@ export interface PrototypeAuthorizationService { targetUserId: string, ): Promise listVersions(sessionToken: string, prototypeId: string, query: unknown): Promise> - createVersion(sessionToken: string, prototypeId: string, input: unknown): Promise getVersion(sessionToken: string, prototypeId: string, versionId: string): Promise } diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts new file mode 100644 index 00000000..ba016379 --- /dev/null +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts @@ -0,0 +1,67 @@ +import { createHash, randomUUID } from "node:crypto" +import { rm } from "node:fs/promises" +import path from "node:path" + +import { describe, expect, it, onTestFinished } from "vitest" + +import { createLocalArtifactStorage } from "#server/integrations/artifact-storage/create-local-storage" + +function storageRoot() { + const root = path.join(process.cwd(), `.artifact-storage-test-${randomUUID()}`) + onTestFinished(() => rm(root, { recursive: true, force: true })) + return root +} + +function digest(bytes: Uint8Array) { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}` +} + +describe("createLocalArtifactStorage()", () => { + it("requires an absolute storage root", () => { + expect(() => createLocalArtifactStorage("relative/artifacts")).toThrow( + "HUB_ARTIFACT_STORAGE_PATH must be an absolute path.", + ) + }) + + it("stores and reads artifacts by a deterministic content-addressed key", async () => { + const storage = createLocalArtifactStorage(storageRoot()) + const bytes = Buffer.from("content-addressed artifact") + const contentDigest = digest(bytes) + + const stored = await storage.put(contentDigest, bytes) + + expect(stored).toEqual({ + key: `sha256/${contentDigest.slice(7, 9)}/${contentDigest.slice(7)}`, + created: true, + }) + expect(Buffer.from(await storage.read(stored.key))).toEqual(bytes) + }) + + it("rejects mismatched bytes without overwriting existing content", async () => { + const storage = createLocalArtifactStorage(storageRoot()) + const original = Buffer.from("immutable artifact") + const contentDigest = digest(original) + + const first = await storage.put(contentDigest, original) + await expect(storage.put(contentDigest, Buffer.from("different bytes"))).rejects.toThrow( + "Artifact bytes do not match the content digest.", + ) + + expect(Buffer.from(await storage.read(first.key))).toEqual(original) + }) + + it("converges concurrent writes for one digest on a single stored object", async () => { + const storage = createLocalArtifactStorage(storageRoot()) + const bytes = Buffer.from("concurrent artifact") + const contentDigest = digest(bytes) + + const results = await Promise.all([ + storage.put(contentDigest, bytes), + storage.put(contentDigest, bytes), + ]) + + expect(new Set(results.map(({ key }) => key))).toEqual(new Set([results[0].key])) + expect(results.filter(({ created }) => created)).toHaveLength(1) + expect(Buffer.from(await storage.read(results[0].key))).toEqual(bytes) + }) +}) diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.ts new file mode 100644 index 00000000..544a2fee --- /dev/null +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.ts @@ -0,0 +1,96 @@ +import { createHash, randomUUID, timingSafeEqual } from "node:crypto" +import { constants } from "node:fs" +import { access, link, mkdir, open, readFile, rm } from "node:fs/promises" +import path from "node:path" + +import type { ArtifactStorage, StoredArtifact } from "#server/integrations/artifact-storage/types" + +function artifactKey(contentDigest: string): string { + if (!/^sha256:[a-f0-9]{64}$/.test(contentDigest)) { + throw new Error("Artifact content digest is invalid.") + } + const digest = contentDigest.replace(/^sha256:/, "") + return path.posix.join("sha256", digest.slice(0, 2), digest) +} + +function storagePath(root: string, key: string): string { + const candidate = path.resolve(root, key) + const resolvedRoot = path.resolve(root) + if (!candidate.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error("Artifact storage key is invalid.") + } + return candidate +} + +function calculateContentDigest(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}` +} + +async function verifyExisting(destination: string, contentDigest: string): Promise { + const expected = Buffer.from(contentDigest.replace(/^sha256:/, ""), "hex") + const actual = createHash("sha256") + .update(await readFile(destination)) + .digest() + if (!timingSafeEqual(expected, actual)) { + throw new Error("Stored artifact content does not match its key.") + } +} + +function isFileSystemError(error: unknown, code: string): boolean { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + error.code === code + ) +} + +export function createLocalArtifactStorage(root: string): ArtifactStorage { + if (!path.isAbsolute(root)) { + throw new Error("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") + } + return { + async put(contentDigest: string, bytes: Uint8Array): Promise { + const key = artifactKey(contentDigest) + if (calculateContentDigest(bytes) !== contentDigest) { + throw new Error("Artifact bytes do not match the content digest.") + } + const destination = storagePath(root, key) + await mkdir(path.dirname(destination), { recursive: true }) + try { + await access(destination, constants.F_OK) + await verifyExisting(destination, contentDigest) + return { key, created: false } + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) throw error + } + + const temporary = `${destination}.${randomUUID()}.tmp` + const file = await open(temporary, "wx", 0o600) + try { + await file.writeFile(bytes) + await file.sync() + } finally { + await file.close() + } + try { + await link(temporary, destination) + return { key, created: true } + } catch (error) { + if (!isFileSystemError(error, "EEXIST")) throw error + await verifyExisting(destination, contentDigest) + return { key, created: false } + } finally { + await rm(temporary, { force: true }) + } + }, + + async read(key: string): Promise { + return readFile(storagePath(root, key)) + }, + + async remove(key: string): Promise { + await rm(storagePath(root, key), { force: true }) + }, + } +} diff --git a/hub/server/integrations/artifact-storage/types.ts b/hub/server/integrations/artifact-storage/types.ts new file mode 100644 index 00000000..43f08e07 --- /dev/null +++ b/hub/server/integrations/artifact-storage/types.ts @@ -0,0 +1,10 @@ +export interface StoredArtifact { + key: string + created: boolean +} + +export interface ArtifactStorage { + put(contentDigest: string, bytes: Uint8Array): Promise + read(key: string): Promise + remove(key: string): Promise +} diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts index 12c3a6dd..d139afde 100644 --- a/hub/server/repositories/security-audits/save-audit/index.ts +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -27,6 +27,7 @@ export async function saveSecurityAudit(database: Prisma.TransactionClient, data workspaceId: data.workspaceId ?? null, prototypeId: data.prototypeId ?? null, prototypeVersionId: data.prototypeVersionId ?? null, + workloadPublisherId: data.workloadPublisherId ?? null, targetUserId: data.targetUserId ?? null, githubRepositorySourceId: data.githubRepositorySourceId ?? null, context, diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index ef4c2ef7..05be2a06 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -9,6 +9,7 @@ export interface AuditInput { workspaceId?: string | null prototypeId?: string | null prototypeVersionId?: string | null + workloadPublisherId?: string | null targetUserId?: string | null githubRepositorySourceId?: string | null context?: Record @@ -26,6 +27,7 @@ export interface SecurityAuditRecord { workspaceId: string | null prototypeId: string | null prototypeVersionId: string | null + workloadPublisherId: string | null targetUserId: string | null githubRepositorySourceId: string | null context: unknown diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 1720c2cf..c648ece9 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -19,7 +19,7 @@ import { post as handleApiSessionVerifyPost } from "#server/routes/api/session/v import { archivePrototype, createPrototype, - createVersion, + createPublisherCredential, createWorkspace, explainPrototypeAccess, getPrototype, @@ -42,6 +42,7 @@ import { unlinkRepository, updateRepository, } from "#server/routes/github-authorization" +import { getPublicationStatus, promoteVersion, publishArtifact } from "#server/routes/publishing" import type { AuthenticationRoutes } from "#server/routes/types" /** Keep endpoint registration separate from the app's shared middleware. */ @@ -153,6 +154,11 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi productMutationLimit, setPrototypeAuthorization.bind(null, authentication), ) + app.post( + "/api/prototypes/:prototypeId/publishers", + productMutationLimit, + createPublisherCredential.bind(null, authentication), + ) app.get( "/api/prototypes/:prototypeId/access/explanation", explainPrototypeAccess.bind(null, authentication), @@ -168,12 +174,30 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi removePrototypeAccess.bind(null, authentication), ) app.get("/api/prototypes/:prototypeId/versions", listVersions.bind(null, authentication)) - app.post( - "/api/prototypes/:prototypeId/versions", - productMutationLimit, - createVersion.bind(null, authentication), - ) app.get("/api/prototypes/:prototypeId/versions/:versionId", getVersion.bind(null, authentication)) app.use(["/api/workspaces", "/api/prototypes"], respondAdministrationError) + if (authentication.artifactPublishing) { + const publishingLimit = createRateLimit({ + limit: 60, + windowMilliseconds: 5 * 60_000, + message: "Too many publishing requests.", + }) + app.post( + "/api/publishing/prototypes/:prototypeId/versions", + publishingLimit, + publishArtifact.bind(null, authentication.artifactPublishing), + ) + app.get( + "/api/publishing/prototypes/:prototypeId/status", + publishingLimit, + getPublicationStatus.bind(null, authentication.artifactPublishing), + ) + app.put( + "/api/publishing/prototypes/:prototypeId/channels/:channel", + publishingLimit, + promoteVersion.bind(null, authentication.artifactPublishing), + ) + app.use("/api/publishing", respondAdministrationError) + } app.use(respondAuthenticationError) } diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts index 40893e1a..ef393101 100644 --- a/hub/server/routes/product/index.test.ts +++ b/hub/server/routes/product/index.test.ts @@ -39,11 +39,11 @@ function runtime( getPrototype: vi.fn(), archivePrototype: vi.fn(), setPrototypeAuthorization: vi.fn(), + createPublisherCredential: vi.fn(), explainPrototypeAccess: vi.fn(), setPrototypeAccess: vi.fn(), removePrototypeAccess: vi.fn(), listVersions: vi.fn(), - createVersion: vi.fn(), getVersion: vi.fn(), }, overrides, @@ -228,6 +228,12 @@ describe("product routes", () => { body: { name: "P" }, }, { method: "GET", path: "/api/prototypes/prototype", call: "getPrototype" }, + { + method: "POST", + path: "/api/prototypes/prototype/publishers", + call: "createPublisherCredential", + body: { name: "Local publisher", actions: ["publish"] }, + }, { method: "POST", path: "/api/prototypes/prototype/archive", @@ -263,12 +269,6 @@ describe("product routes", () => { path: "/api/prototypes/prototype/versions", call: "listVersions", }, - { - method: "POST", - path: "/api/prototypes/prototype/versions", - call: "createVersion", - body: { label: "Version" }, - }, { method: "GET", path: "/api/prototypes/prototype/versions/version", diff --git a/hub/server/routes/product/index.ts b/hub/server/routes/product/index.ts index 00d0ea2a..bebe917d 100644 --- a/hub/server/routes/product/index.ts +++ b/hub/server/routes/product/index.ts @@ -152,6 +152,19 @@ export async function setPrototypeAuthorization( response.json({ prototype }) } +export async function createPublisherCredential( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const result = await authentication.prototypeAuthorization.createPublisherCredential( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + request.body, + ) + response.status(201).json(result) +} + export async function explainPrototypeAccess( authentication: AuthenticationRoutes, request: Request, @@ -205,19 +218,6 @@ export async function listVersions( ) } -export async function createVersion( - authentication: AuthenticationRoutes, - request: Request, - response: Response, -): Promise { - const version = await authentication.prototypeAuthorization.createVersion( - mutationSessionToken(authentication, request), - parameter(request, "prototypeId"), - request.body, - ) - response.status(201).json({ version }) -} - export async function getVersion( authentication: AuthenticationRoutes, request: Request, diff --git a/hub/server/routes/publishing.test.ts b/hub/server/routes/publishing.test.ts new file mode 100644 index 00000000..c22888f5 --- /dev/null +++ b/hub/server/routes/publishing.test.ts @@ -0,0 +1,235 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" + +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import { maximumPublishingJsonBytes } from "#server/domains/artifact-publishing/create-service/constants" +import type { ArtifactPublishingService } from "#server/domains/artifact-publishing/create-service/types" +import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" +import { createLogger } from "#server/integrations/pino/create-logger" +import type { AuthenticationRoutes } from "#server/routes/types" + +import { createApp } from "../create-app/index.ts" + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { artifactPublishing: Mocked } { + const artifactPublishing = Object.assign( + { + publish: vi.fn(), + getStatus: vi.fn(), + promote: vi.fn(), + }, + overrides, + ) as Mocked + return { + service: {} as Mocked, + administration: {} as Mocked, + prototypeAuthorization: {} as Mocked, + artifactPublishing, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes, logger = createLogger()) { + const server = createServer(createApp({ authentication, logger })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe("publishing routes", () => { + it("passes workload authorization and idempotency to publication without browser state", async () => { + const publish = vi.fn().mockResolvedValue({ + version: { id: "version-id" }, + replayed: false, + promotedChannel: null, + }) + const authentication = runtime({ + publish, + }) + const baseUrl = await serve(authentication) + const body = { + manifest: { schemaVersion: 1 }, + artifactBase64: "cHJpdmF0ZS1hcnRpZmFjdA==", + } + + const response = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "Bearer workload-token", + "content-type": "application/json", + "idempotency-key": "request-key", + }, + body: JSON.stringify(body), + }) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ + version: { id: "version-id" }, + replayed: false, + promotedChannel: null, + }) + expect(publish).toHaveBeenCalledWith( + "Bearer workload-token", + "prototype-id", + "request-key", + body, + ) + }) + + it("returns 200 for an idempotent publication replay", async () => { + const authentication = runtime({ + publish: vi.fn().mockResolvedValue({ + version: { id: "same-version" }, + replayed: true, + promotedChannel: "preview", + }), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "Bearer workload-token", + "content-type": "application/json", + "idempotency-key": "retry-key", + }, + body: JSON.stringify({ manifest: {}, artifactBase64: "" }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + version: { id: "same-version" }, + replayed: true, + }) + }) + + it("routes status reads and channel promotion through workload credentials", async () => { + const getStatus = vi.fn().mockResolvedValue({ publicationId: "publication-id" }) + const promote = vi.fn().mockResolvedValue({ + name: "current", + prototypeVersionId: "version-id", + }) + const authentication = runtime({ + getStatus, + promote, + }) + const baseUrl = await serve(authentication) + const headers = { + authorization: "Bearer workload-token", + "idempotency-key": "request-key", + } + + const status = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/status`, { + headers, + }) + const promotion = await fetch( + `${baseUrl}/api/publishing/prototypes/prototype-id/channels/current`, + { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ versionId: "version-id" }), + }, + ) + + expect(status.status).toBe(200) + expect(await status.json()).toEqual({ publicationId: "publication-id" }) + expect(getStatus).toHaveBeenCalledWith("Bearer workload-token", "prototype-id", "request-key") + expect(promotion.status).toBe(200) + expect(await promotion.json()).toEqual({ + channel: { name: "current", prototypeVersionId: "version-id" }, + }) + expect(promote).toHaveBeenCalledWith("Bearer workload-token", "prototype-id", "current", { + versionId: "version-id", + }) + }) + + it("omits workload authorization, query values, and artifact content from request logs", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const authentication = runtime({ + publish: vi.fn().mockResolvedValue({ + version: { id: "version-id" }, + replayed: false, + promotedChannel: null, + }), + }) + const baseUrl = await serve(authentication, logger) + + const response = await fetch( + `${baseUrl}/api/publishing/prototypes/prototype-id/versions?note=private-query`, + { + method: "POST", + headers: { + authorization: "Bearer private-workload-token", + "content-type": "application/json", + "idempotency-key": "request-key", + }, + body: JSON.stringify({ + manifest: { provenance: { revision: "private-revision" } }, + artifactBase64: "cHJpdmF0ZS1hcnRpZmFjdC1jb250ZW50", + }), + }, + ) + + expect(response.status).toBe(201) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("query") + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toMatch( + /private-workload-token|private-query|private-revision|cHJpdmF0ZS1hcnRpZmFjdC1jb250ZW50/, + ) + }) + + it("rejects a publishing body above its dedicated limit before the service runs", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const publish = vi.fn() + const authentication = runtime({ publish }) + const baseUrl = await serve(authentication, logger) + const privateContent = "x".repeat(maximumPublishingJsonBytes) + + const response = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "Bearer private-workload-token", + "content-type": "application/json", + "idempotency-key": "oversized-request", + }, + body: JSON.stringify({ artifactBase64: privateContent }), + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ error: "Invalid request" }) + expect(publish).not.toHaveBeenCalled() + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toContain(privateContent.slice(0, 100)) + }) +}) diff --git a/hub/server/routes/publishing/index.ts b/hub/server/routes/publishing/index.ts new file mode 100644 index 00000000..e87057e4 --- /dev/null +++ b/hub/server/routes/publishing/index.ts @@ -0,0 +1,58 @@ +import type { Request, Response } from "express" + +import type { ArtifactPublishingService } from "#server/domains/artifact-publishing/create-service/types" + +function parameter(request: Request, name: string): string { + const value = request.params[name] + return typeof value === "string" ? value : "" +} + +function authorization(request: Request): string { + return request.get("authorization") ?? "" +} + +function idempotencyKey(request: Request): string { + return request.get("idempotency-key") ?? "" +} + +export async function publishArtifact( + artifactPublishing: ArtifactPublishingService, + request: Request, + response: Response, +): Promise { + const result = await artifactPublishing.publish( + authorization(request), + parameter(request, "prototypeId"), + idempotencyKey(request), + request.body, + ) + response.status(result.replayed ? 200 : 201).json(result) +} + +export async function getPublicationStatus( + artifactPublishing: ArtifactPublishingService, + request: Request, + response: Response, +): Promise { + response.json( + await artifactPublishing.getStatus( + authorization(request), + parameter(request, "prototypeId"), + idempotencyKey(request), + ), + ) +} + +export async function promoteVersion( + artifactPublishing: ArtifactPublishingService, + request: Request, + response: Response, +): Promise { + const channel = await artifactPublishing.promote( + authorization(request), + parameter(request, "prototypeId"), + parameter(request, "channel"), + request.body, + ) + response.json({ channel }) +} diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index 530c61ce..d4a86df7 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -5,11 +5,13 @@ import type { import type { AdministrationService } from "#server/domains/administration/create-service/types" import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" +import type { ArtifactPublishingService } from "#server/domains/artifact-publishing/create-service/types" export interface AuthenticationRoutes { service: AuthenticationService administration: AdministrationService prototypeAuthorization: PrototypeAuthorizationService githubAuthorization?: GitHubAuthorizationService + artifactPublishing?: ArtifactPublishingService settings: AuthenticationSettings } From 2a834df66e5b0d701504c7ea8fe1eb604a5a8a18 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 16:42:31 -0700 Subject: [PATCH 29/56] fix(hub): harden artifact publishing boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/artifact-ingestion.md | 8 +- hub/docs/database.md | 4 +- hub/docs/prototype-authorization.md | 2 + .../migration.sql | 5 + hub/schema/security-audit.prisma | 2 + .../create-service/index.db.test.ts | 195 ++++++++++++ .../create-service/index.ts | 24 +- .../create-service/index.db.test.ts | 282 ++++++++++++++++++ .../create-service/index.ts | 120 ++++++++ .../create-service/types.ts | 12 + .../create-local-storage/index.test.ts | 139 ++++++++- .../create-local-storage/index.ts | 159 ++++++++-- .../security-audits/save-audit/index.ts | 1 + .../repositories/security-audits/types.ts | 2 + hub/server/routes/index.ts | 12 + hub/server/routes/product/index.test.ts | 13 + hub/server/routes/product/index.ts | 28 ++ 17 files changed, 973 insertions(+), 35 deletions(-) create mode 100644 hub/schema/migrations/20261027231000_publisher_lifecycle/migration.sql diff --git a/hub/docs/artifact-ingestion.md b/hub/docs/artifact-ingestion.md index b2c5bdd1..8f79582c 100644 --- a/hub/docs/artifact-ingestion.md +++ b/hub/docs/artifact-ingestion.md @@ -13,7 +13,9 @@ A prototype administrator creates a workload publisher with one or both actions: Hub returns a bearer credential once and stores only its SHA-256 digest. The credential is scoped to one prototype through its workload publisher. It cannot create a browser session, view a prototype through user APIs, administer access, or publish to another prototype. A producer's source URL, revision, ref, pipeline, and run ID describe where a build came from; they never grant access. -Browser users provision credentials through `POST /api/prototypes/:prototypeId/publishers`. This requires a fresh prototype-administrator session and returns the raw token only in that response. +Browser users provision credentials through `POST /api/prototypes/:prototypeId/publishers`. This requires a fresh prototype-administrator session and returns the raw token only in that response. The same administrator may temporarily disable or re-enable a publisher with `PATCH /api/prototypes/:prototypeId/publishers/:publisherId`, or permanently revoke one credential with `DELETE /api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId`. Hub audits each state change. Re-enabling a publisher does not restore a revoked credential. + +Archiving a prototype blocks new publishers, publication, publisher re-enablement, and `current` or `preview` promotion. Administrators may still disable publishers and revoke credentials after archival. Existing versions and publication status remain readable through their authorized interfaces. ## Manifest contract @@ -45,13 +47,13 @@ Browser users provision credentials through `POST /api/prototypes/:prototypeId/p } ``` -The manifest is closed to unknown fields. Hub bounds every string, accepts only HTTP or HTTPS source URLs, limits decoded artifacts to 8 MiB in this reference layer, verifies the decoded size, and recalculates the SHA-256 digest before storing bytes. The manifest prototype ID must match the route. +The manifest is closed to unknown fields. Hub bounds every string, accepts only HTTP or HTTPS source URLs without embedded usernames or passwords, limits decoded artifacts to 8 MiB in this reference layer, verifies the decoded size, and recalculates the SHA-256 digest before storing bytes. The manifest prototype ID must match the route. ## Storage boundary `ArtifactStorage` separates ingestion from byte storage. The reference adapter writes content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`, defaulting to `hub/.data/artifacts` for local use. Database rows store only the storage key and validated metadata, never the artifact bytes. -The local adapter is suitable for one Hub process with a durable mounted filesystem. It does not provide replication, remote durability, encryption-key management, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. +The local adapter is suitable for one Hub process with a durable mounted filesystem. It rejects symbolic-link roots, shards, and artifact paths, resolves directories under the configured root, and opens artifact files without following a final symbolic link. Operators must keep the storage directory writable only by the Hub workload; the local adapter does not provide replication, remote durability, encryption-key management, hostile concurrent filesystem isolation, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. Storage happens before the database transaction. A failed transaction can therefore leave an unreferenced content-addressed file. Hub never deletes referenced bytes automatically. Operators may remove only files that have no matching `PrototypeVersion.storageKey`; retain unreferenced files long enough to exclude in-flight transactions and backups. Version metadata has no deletion API and must not be rewritten silently. diff --git a/hub/docs/database.md b/hub/docs/database.md index e8dd7624..73c8441b 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -55,10 +55,10 @@ GitHub synchronization uses separate records: - `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. - `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. - `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. -`Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. + `Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. -A database trigger rejects prototype version updates and deletions so history cannot be rewritten or silently removed. Workload credentials store token digests only. Publication rows bind a publisher-scoped idempotency-key digest to one version, and channel rows keep mutable pointers separate from immutable version metadata. Artifact bytes stay outside relational columns behind the storage boundary. +A database trigger rejects prototype version updates and deletions so history cannot be rewritten or silently removed. Workload credentials store token digests plus optional expiry and revocation timestamps; publishers have an independent active flag for temporary suspension. Publication rows bind a publisher-scoped idempotency-key digest to one version, and channel rows keep mutable pointers separate from immutable version metadata. Artifact bytes stay outside relational columns behind the storage boundary. Installation membership `source` distinguishes linked-source synchronization from the `legacy-synchronized` compatibility policy. Any administrator lifecycle change or assignment of Hub administration converts a synchronized membership to `manual`, so reconciliation cannot undo the Hub override or orphan administration. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 1bf32fe2..56fabb6b 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -77,6 +77,8 @@ The product API uses: - `GET /api/prototypes/:prototypeId/versions` - `GET /api/prototypes/:prototypeId/versions/:versionId` - `POST /api/prototypes/:prototypeId/publishers` +- `PATCH /api/prototypes/:prototypeId/publishers/:publisherId` +- `DELETE /api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId` List routes use an opaque UUID cursor and a limit from 1 through 100. Prototype and version cursors belong to their owning workspace or prototype; a cursor from another scope is invalid. Invalid pagination returns `422 invalid_pagination`, while malformed or missing path resources return their resource-specific `404` error. State-changing requests require the configured origin and matching CSRF cookie and header. Product responses are private and non-cacheable, request payloads are omitted from logs, and mutation routes use a bounded rate limit. diff --git a/hub/schema/migrations/20261027231000_publisher_lifecycle/migration.sql b/hub/schema/migrations/20261027231000_publisher_lifecycle/migration.sql new file mode 100644 index 00000000..bbac9505 --- /dev/null +++ b/hub/schema/migrations/20261027231000_publisher_lifecycle/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "security_audits" ADD COLUMN "workload_credential_id" UUID; + +-- CreateIndex +CREATE INDEX "security_audits_workload_credential_id_idx" ON "security_audits"("workload_credential_id"); diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index cbc1f36a..7075b111 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -10,6 +10,7 @@ model SecurityAudit { prototypeId String? @map("prototype_id") @db.Uuid prototypeVersionId String? @map("prototype_version_id") @db.Uuid workloadPublisherId String? @map("workload_publisher_id") @db.Uuid + workloadCredentialId String? @map("workload_credential_id") @db.Uuid targetUserId String? @map("target_user_id") @db.Uuid githubRepositorySourceId String? @map("github_repository_source_id") @db.Uuid @@ -34,6 +35,7 @@ model SecurityAudit { @@index([prototypeId]) @@index([prototypeVersionId]) @@index([workloadPublisherId]) + @@index([workloadCredentialId]) @@index([targetUserId]) @@index([githubRepositorySourceId]) @@map("security_audits") diff --git a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts index 0c6103b9..a25e1418 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts @@ -141,6 +141,44 @@ async function createAdministratorSession(prisma: PrismaClient, token: string) { return { actor, store } } +async function provisionPublisher(prisma: PrismaClient, sessionToken: string) { + const { actor, store } = await createAdministratorSession(prisma, sessionToken) + const workspace = await prisma.workspace.create({ + data: { + name: "Publisher workspace", + createdByUserId: actor.id, + grants: { + create: { + userId: actor.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Publisher prototype", + workspaceId: workspace.id, + createdByUserId: actor.id, + }, + }) + const authorization = createPrototypeAuthorizationService({ database: prisma, store }) + const provisioned = (await authorization.createPublisherCredential(sessionToken, prototype.id, { + name: "CI publisher", + actions: ["publish", "read-status"], + })) as { + publisher: { id: string } + credential: { id: string; token: string } + } + return { + authorization, + prototype, + provisioned, + workloadAuthorization: `Bearer ${provisioned.credential.token}`, + } +} + async function createCredential( prisma: PrismaClient, prototypeId: string, @@ -219,6 +257,32 @@ describe("createArtifactPublishingService() database behavior", () => { }, }), }, + { + name: "provenance URL username", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { + ...valid.manifest.provenance, + sourceUrl: "https://publisher@ci.example/runs/123", + }, + }, + }), + }, + { + name: "provenance URL password", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { + ...valid.manifest.provenance, + sourceUrl: "https://publisher:secret@ci.example/runs/123", + }, + }, + }), + }, { name: "different prototype identifier", change: (valid: ReturnType) => ({ @@ -335,6 +399,97 @@ describe("createArtifactPublishingService() database behavior", () => { expect(JSON.stringify(saved)).not.toContain(provisioned.credential.token) }) + it("accepts and persists an ordinary HTTPS provenance source URL", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const sourceUrl = "https://ci.example/runs/ordinary" + + const result = await context.service.publish( + credential.authorization, + prototype.id, + "ordinary-source-url", + publicationInput(prototype.id, artifact("ordinary-source-url"), { + manifest: manifest(prototype.id, artifact("ordinary-source-url"), { + provenance: { + provider: "build-system", + sourceUrl, + revision: "abc123", + ref: "refs/heads/main", + pipeline: "publish", + runId: "123", + }, + }), + }), + ) + + expect( + await context.prisma.prototypeVersion.findUniqueOrThrow({ + where: { id: publishedVersion(result).id }, + select: { provenanceSourceUrl: true }, + }), + ).toEqual({ provenanceSourceUrl: sourceUrl }) + }) + + it("keeps a revoked credential unusable after its publisher is disabled and re-enabled", async () => { + const context = await database() + const sessionToken = "publisher-lifecycle-session" + const { authorization, prototype, provisioned, workloadAuthorization } = + await provisionPublisher(context.prisma, sessionToken) + + await context.service.publish( + workloadAuthorization, + prototype.id, + "before-disable", + publicationInput(prototype.id, artifact("before-disable")), + ) + await authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: false, + }) + await expect( + context.service.publish( + workloadAuthorization, + prototype.id, + "while-disabled", + publicationInput(prototype.id, artifact("while-disabled")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + + await authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: true, + }) + await context.service.publish( + workloadAuthorization, + prototype.id, + "after-enable", + publicationInput(prototype.id, artifact("after-enable")), + ) + await authorization.revokePublisherCredential( + sessionToken, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ) + await authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: false, + }) + await authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: true, + }) + + await expect( + context.service.publish( + workloadAuthorization, + prototype.id, + "after-revocation", + publicationInput(prototype.id, artifact("after-revocation")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(2) + }) + it("enforces publish and read-status action scopes independently", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) @@ -544,6 +699,46 @@ describe("createArtifactPublishingService() database behavior", () => { ]) }) + it("blocks publication and current or preview promotion after a prototype is archived", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const published = await context.service.publish( + credential.authorization, + prototype.id, + "before-archive", + publicationInput(prototype.id, artifact("before-archive")), + ) + const version = publishedVersion(published) + await context.prisma.prototype.update({ + where: { id: prototype.id }, + data: { archivedAt: new Date() }, + }) + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "after-archive", + publicationInput(prototype.id, artifact("after-archive")), + ), + ).rejects.toMatchObject({ status: 409, code: "prototype_archived" }) + for (const channel of ["current", "preview"] as const) { + await expect( + context.service.promote(credential.authorization, prototype.id, channel, { + versionId: version.id, + }), + ).rejects.toMatchObject({ status: 409, code: "prototype_archived" }) + } + + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(1) + expect( + await context.prisma.prototypeChannel.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + }) + it("rolls back a published version when its audit cannot be saved", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) diff --git a/hub/server/domains/artifact-publishing/create-service/index.ts b/hub/server/domains/artifact-publishing/create-service/index.ts index e6316bb3..39ad72d0 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.ts @@ -30,7 +30,14 @@ const manifestSchema = z sourceUrl: z .url() .max(2_048) - .refine((value) => ["http:", "https:"].includes(new URL(value).protocol)) + .refine((value) => { + const url = new URL(value) + return ( + ["http:", "https:"].includes(url.protocol) && + url.username.length === 0 && + url.password.length === 0 + ) + }) .optional(), revision: boundedIdentifier.optional(), ref: boundedIdentifier.optional(), @@ -148,6 +155,17 @@ export function createArtifactPublishingService({ database: PrismaClient storage: ArtifactStorage }): ArtifactPublishingService { + async function requireUnarchivedPrototype(prototypeId: string) { + const prototype = await database.prototype.findUnique({ + where: { id: prototypeId }, + select: { archivedAt: true }, + }) + if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") + if (prototype.archivedAt) { + error(409, "prototype_archived", "Archived prototypes cannot be published.") + } + } + async function authenticate(authorization: string, prototypeId: string, action: PublisherAction) { parsePrototypeId(prototypeId) const match = authorizationSchema.exec(authorization) @@ -176,6 +194,7 @@ export function createArtifactPublishingService({ return { async publish(authorization, prototypeId, idempotencyKey, input) { const publisher = await authenticate(authorization, prototypeId, "publish") + await requireUnarchivedPrototype(prototypeId) const parsed = publishSchema.safeParse(input) if (!parsed.success || parsed.data.manifest.prototypeId !== prototypeId) { error(422, "invalid_artifact_manifest", "The artifact manifest is invalid.") @@ -336,6 +355,9 @@ export function createArtifactPublishingService({ await lockPrototype(transaction, prototypeId) const prototype = await transaction.prototype.findUnique({ where: { id: prototypeId } }) if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") + if (prototype.archivedAt) { + error(409, "prototype_archived", "Archived prototypes cannot be promoted.") + } const version = await transaction.prototypeVersion.findFirst({ where: { id: parsed.data.versionId, prototypeId }, }) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index df58414b..267ba5b1 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -137,6 +137,58 @@ async function holdPrototypeLock(database: PrismaClient, prototypeId: string) { return { holder, release: released.resolve } } +async function createAdministeredPrototype({ + service, + store, + subject, + token, +}: { + service: ReturnType + store: IdentityStore + subject: string + token: string +}) { + const actor = await createActor(store, { subject, token, role: "administrator" }) + const workspace = (await service.createWorkspace(token, { + name: `${subject} workspace`, + })) as { id: string } + const prototype = (await service.createPrototype(token, workspace.id, { + name: `${subject} prototype`, + })) as { id: string } + return { actor, prototype, workspace } +} + +async function rejectAudit( + database: PrismaClient, + event: + | "prototype.publisher.create" + | "prototype.publisher.disable" + | "prototype.publisher.credential.revoke", + action: () => Promise, +) { + await database.$executeRawUnsafe(` + CREATE FUNCTION reject_test_publisher_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = '${event}' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await database.$executeRawUnsafe(` + CREATE TRIGGER reject_test_publisher_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_test_publisher_audit(); + `) + try { + await expect(action()).rejects.toThrow("audit unavailable") + } finally { + await database.$executeRawUnsafe("DROP TRIGGER reject_test_publisher_audit ON security_audits") + await database.$executeRawUnsafe("DROP FUNCTION reject_test_publisher_audit()") + } +} + describe("createPrototypeAuthorizationService() database behavior", () => { it("separates installation authority from prototype access and explicitly grants a creator", async () => { const { prisma, service, store } = await database() @@ -795,6 +847,236 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("audits publisher disable, re-enable, and credential revocation with their state changes", async () => { + const { prisma, service, store } = await database() + const token = "publisher-lifecycle-token" + const { actor, prototype } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-lifecycle", + token, + }) + + await rejectAudit(prisma, "prototype.publisher.create", () => + service.createPublisherCredential(token, prototype.id, { + name: "Rejected publisher", + actions: ["publish"], + }), + ) + expect(await prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } })).toBe(0) + + const provisioned = (await service.createPublisherCredential(token, prototype.id, { + name: "CI publisher", + actions: ["publish", "read-status"], + })) as { + publisher: { id: string } + credential: { id: string } + } + + await rejectAudit(prisma, "prototype.publisher.disable", () => + service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: false, + }), + ) + expect( + await prisma.workloadPublisher.findUniqueOrThrow({ + where: { id: provisioned.publisher.id }, + select: { active: true }, + }), + ).toEqual({ active: true }) + + await service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: false, + }) + await service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: true, + }) + await rejectAudit(prisma, "prototype.publisher.credential.revoke", () => + service.revokePublisherCredential( + token, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ), + ) + expect( + await prisma.workloadCredential.findUniqueOrThrow({ + where: { id: provisioned.credential.id }, + select: { revokedAt: true }, + }), + ).toEqual({ revokedAt: null }) + + await service.revokePublisherCredential( + token, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ) + + const revokedCredential = await prisma.workloadCredential.findUniqueOrThrow({ + where: { id: provisioned.credential.id }, + select: { revokedAt: true }, + }) + expect(revokedCredential.revokedAt).toBeInstanceOf(Date) + expect( + await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id, workloadPublisherId: provisioned.publisher.id }, + select: { + actorUserId: true, + event: true, + workloadCredentialId: true, + workloadPublisherId: true, + }, + }), + ).toEqual( + expect.arrayContaining([ + { + actorUserId: actor.user.id, + event: "prototype.publisher.create", + workloadCredentialId: null, + workloadPublisherId: provisioned.publisher.id, + }, + { + actorUserId: actor.user.id, + event: "prototype.publisher.disable", + workloadCredentialId: null, + workloadPublisherId: provisioned.publisher.id, + }, + { + actorUserId: actor.user.id, + event: "prototype.publisher.enable", + workloadCredentialId: null, + workloadPublisherId: provisioned.publisher.id, + }, + { + actorUserId: actor.user.id, + event: "prototype.publisher.credential.revoke", + workloadCredentialId: provisioned.credential.id, + workloadPublisherId: provisioned.publisher.id, + }, + ]), + ) + expect( + await prisma.securityAudit.count({ + where: { prototypeId: prototype.id, workloadPublisherId: provisioned.publisher.id }, + }), + ).toBe(4) + }) + + it("does not apply publisher or credential lifecycle operations across prototypes", async () => { + const { prisma, service, store } = await database() + const token = "publisher-scope-token" + const { prototype, workspace } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-scope", + token, + }) + const otherPrototype = (await service.createPrototype(token, workspace.id, { + name: "Other prototype", + })) as { id: string } + const provisioned = (await service.createPublisherCredential(token, prototype.id, { + name: "Scoped publisher", + actions: ["publish"], + })) as { + publisher: { id: string } + credential: { id: string } + } + + await expect( + service.setPublisherActive(token, otherPrototype.id, provisioned.publisher.id, { + active: false, + }), + ).rejects.toMatchObject({ status: 404, code: "workload_publisher_not_found" }) + await expect( + service.revokePublisherCredential( + token, + otherPrototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ), + ).rejects.toMatchObject({ status: 404, code: "workload_credential_not_found" }) + + expect( + await prisma.workloadPublisher.findUniqueOrThrow({ + where: { id: provisioned.publisher.id }, + select: { active: true, credentials: { select: { revokedAt: true } } }, + }), + ).toEqual({ active: true, credentials: [{ revokedAt: null }] }) + expect( + await prisma.securityAudit.count({ + where: { + prototypeId: otherPrototype.id, + event: { startsWith: "prototype.publisher." }, + }, + }), + ).toBe(0) + }) + + it("allows disable and credential revocation after archive but blocks creation and re-enable", async () => { + const { prisma, service, store } = await database() + const token = "archived-publisher-token" + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: "archived-publisher", + token, + }) + const provisioned = (await service.createPublisherCredential(token, prototype.id, { + name: "Archived publisher", + actions: ["publish"], + })) as { + publisher: { id: string } + credential: { id: string } + } + await service.archivePrototype(token, prototype.id) + + await expect( + service.createPublisherCredential(token, prototype.id, { + name: "New archived publisher", + actions: ["publish"], + }), + ).rejects.toMatchObject({ status: 409, code: "prototype_archived" }) + await service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: false, + }) + await expect( + service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: true, + }), + ).rejects.toMatchObject({ status: 409, code: "prototype_archived" }) + await service.revokePublisherCredential( + token, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ) + + const publisher = await prisma.workloadPublisher.findUniqueOrThrow({ + where: { id: provisioned.publisher.id }, + select: { active: true, credentials: { select: { revokedAt: true } } }, + }) + expect(publisher.active).toBe(false) + expect(publisher.credentials).toHaveLength(1) + expect(publisher.credentials[0]?.revokedAt).toBeInstanceOf(Date) + expect( + ( + await prisma.securityAudit.findMany({ + where: { + prototypeId: prototype.id, + event: { + in: ["prototype.publisher.disable", "prototype.publisher.credential.revoke"], + }, + }, + select: { event: true }, + }) + ).sort((left, right) => left.event.localeCompare(right.event)), + ).toEqual([ + { event: "prototype.publisher.credential.revoke" }, + { event: "prototype.publisher.disable" }, + ]) + }) + it("rechecks membership in the mutation transaction instead of trusting a stale session", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index a91df7f1..5743b0a5 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -33,6 +33,7 @@ const publisherSchema = z expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), }) .strict() +const publisherStateSchema = z.object({ active: z.boolean() }).strict() const pageSchema = z.object({ cursor: z.uuid().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), @@ -961,6 +962,13 @@ export function createPrototypeAuthorizationService({ baseline, ) requireDecision(decision, "administrator") + if (prototype.archivedAt) { + throw new AuthenticationError( + 409, + "prototype_archived", + "Archived prototypes cannot receive publishers.", + ) + } const credentialId = randomUUID() const token = `rpl_pub_${credentialId}.${randomBytes(32).toString("base64url")}` const publisher = await transaction.workloadPublisher.create({ @@ -998,6 +1006,118 @@ export function createPrototypeAuthorizationService({ }) }, + async setPublisherActive(sessionToken, prototypeId, publisherId, input) { + const parsed = publisherStateSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_workload_publisher", + "The workload publisher is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + requireIdentifier( + publisherId, + "workload_publisher_not_found", + "The workload publisher was not found.", + ) + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + baseline, + ) + requireDecision(decision, "administrator") + if (prototype.archivedAt && parsed.data.active) { + throw new AuthenticationError( + 409, + "prototype_archived", + "Archived prototypes cannot enable publishers.", + ) + } + const publisher = await transaction.workloadPublisher.findFirst({ + where: { id: publisherId, prototypeId }, + }) + if (!publisher) { + throw new AuthenticationError( + 404, + "workload_publisher_not_found", + "The workload publisher was not found.", + ) + } + if (publisher.active === parsed.data.active) return publisher + const updated = await transaction.workloadPublisher.update({ + where: { id: publisher.id }, + data: { active: parsed.data.active }, + }) + await audit(transaction, actor, { + event: parsed.data.active ? "prototype.publisher.enable" : "prototype.publisher.disable", + workspaceId: prototype.workspaceId, + prototypeId, + workloadPublisherId: publisher.id, + context: { source: "prototype_administrator" }, + }) + return updated + }) + }, + + async revokePublisherCredential(sessionToken, prototypeId, publisherId, credentialId) { + await store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + requireIdentifier( + publisherId, + "workload_publisher_not_found", + "The workload publisher was not found.", + ) + requireIdentifier( + credentialId, + "workload_credential_not_found", + "The workload credential was not found.", + ) + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + baseline, + ) + requireDecision(decision, "administrator") + const credential = await transaction.workloadCredential.findFirst({ + where: { + id: credentialId, + workloadPublisherId: publisherId, + workloadPublisher: { prototypeId }, + }, + }) + if (!credential) { + throw new AuthenticationError( + 404, + "workload_credential_not_found", + "The workload credential was not found.", + ) + } + if (credential.revokedAt) return + await transaction.workloadCredential.update({ + where: { id: credential.id }, + data: { revokedAt: new Date() }, + }) + await audit(transaction, actor, { + event: "prototype.publisher.credential.revoke", + workspaceId: prototype.workspaceId, + prototypeId, + workloadPublisherId: publisherId, + workloadCredentialId: credential.id, + context: { source: "prototype_administrator" }, + }) + }) + }, + async archivePrototype(sessionToken, prototypeId) { return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index eec58cb6..21cfb109 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -29,6 +29,18 @@ export interface PrototypeAuthorizationService { prototypeId: string, input: unknown, ): Promise + setPublisherActive( + sessionToken: string, + prototypeId: string, + publisherId: string, + input: unknown, + ): Promise + revokePublisherCredential( + sessionToken: string, + prototypeId: string, + publisherId: string, + credentialId: string, + ): Promise archivePrototype(sessionToken: string, prototypeId: string): Promise setPrototypeAuthorization( sessionToken: string, diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts index ba016379..e72e9f55 100644 --- a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto" -import { rm } from "node:fs/promises" +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises" import path from "node:path" import { describe, expect, it, onTestFinished } from "vitest" @@ -16,6 +16,58 @@ function digest(bytes: Uint8Array) { return `sha256:${createHash("sha256").update(bytes).digest("hex")}` } +function artifactPath(root: string, contentDigest: string) { + const hash = contentDigest.slice("sha256:".length) + return { + algorithm: path.join(root, "sha256"), + shard: path.join(root, "sha256", hash.slice(0, 2)), + artifact: path.join(root, "sha256", hash.slice(0, 2), hash), + key: `sha256/${hash.slice(0, 2)}/${hash}`, + } +} + +function bytesInShard(shard: string) { + for (let index = 0; ; index += 1) { + const bytes = Buffer.from(`outside write probe ${index}`) + if (digest(bytes).slice("sha256:".length, "sha256:".length + 2) === shard) return bytes + } +} + +async function expectSymlinkRejected({ + root, + key, + contentDigest, + bytes, + sentinel, + writeProbe, +}: { + root: string + key: string + contentDigest: string + bytes: Uint8Array + sentinel: string + writeProbe?: { bytes: Uint8Array; outsidePath: string } +}) { + const storage = createLocalArtifactStorage(root) + + await expect(storage.read(key)).rejects.toThrow( + "Artifact storage paths cannot contain symbolic links.", + ) + await expect(storage.put(contentDigest, bytes)).rejects.toThrow( + "Artifact storage paths cannot contain symbolic links.", + ) + if (writeProbe) { + await expect(storage.put(digest(writeProbe.bytes), writeProbe.bytes)).rejects.toThrow( + "Artifact storage paths cannot contain symbolic links.", + ) + await expect(readFile(writeProbe.outsidePath)).rejects.toMatchObject({ code: "ENOENT" }) + } + await expect(storage.remove(key)).rejects.toThrow( + "Artifact storage paths cannot contain symbolic links.", + ) + expect(await readFile(sentinel)).toEqual(bytes) +} + describe("createLocalArtifactStorage()", () => { it("requires an absolute storage root", () => { expect(() => createLocalArtifactStorage("relative/artifacts")).toThrow( @@ -64,4 +116,89 @@ describe("createLocalArtifactStorage()", () => { expect(results.filter(({ created }) => created)).toHaveLength(1) expect(Buffer.from(await storage.read(results[0].key))).toEqual(bytes) }) + + it("rejects a symbolic-link storage root without reading, replaying, or removing outside content", async () => { + const root = storageRoot() + const outside = storageRoot() + const bytes = Buffer.from("outside root sentinel") + const contentDigest = digest(bytes) + const location = artifactPath(outside, contentDigest) + await mkdir(location.shard, { recursive: true }) + await writeFile(location.artifact, bytes) + await symlink(outside, root, "dir") + const writeBytes = Buffer.from("outside write probe") + + await expectSymlinkRejected({ + root, + key: location.key, + contentDigest, + bytes, + sentinel: location.artifact, + writeProbe: { + bytes: writeBytes, + outsidePath: artifactPath(outside, digest(writeBytes)).artifact, + }, + }) + }) + + it.each(["sha256", "shard"] as const)( + "rejects a symbolic-link %s directory without reading, replaying, or removing outside content", + async (level) => { + const root = storageRoot() + const outside = storageRoot() + const bytes = Buffer.from(`outside ${level} sentinel`) + const contentDigest = digest(bytes) + const inside = artifactPath(root, contentDigest) + const outsideLocation = artifactPath(outside, contentDigest) + const writeBytes = + level === "shard" + ? bytesInShard(contentDigest.slice("sha256:".length, "sha256:".length + 2)) + : Buffer.from("outside write probe") + + if (level === "sha256") { + await mkdir(outsideLocation.shard, { recursive: true }) + await writeFile(outsideLocation.artifact, bytes) + await mkdir(root, { recursive: true }) + await symlink(outsideLocation.algorithm, inside.algorithm, "dir") + } else { + await mkdir(inside.algorithm, { recursive: true }) + await mkdir(outsideLocation.shard, { recursive: true }) + await writeFile(outsideLocation.artifact, bytes) + await symlink(outsideLocation.shard, inside.shard, "dir") + } + + await expectSymlinkRejected({ + root, + key: inside.key, + contentDigest, + bytes, + sentinel: outsideLocation.artifact, + writeProbe: { + bytes: writeBytes, + outsidePath: artifactPath(outside, digest(writeBytes)).artifact, + }, + }) + }, + ) + + it("rejects a symbolic-link artifact without reading, replaying, or removing its outside target", async () => { + const root = storageRoot() + const outside = storageRoot() + const bytes = Buffer.from("outside artifact sentinel") + const contentDigest = digest(bytes) + const location = artifactPath(root, contentDigest) + const sentinel = path.join(outside, "sentinel") + await mkdir(location.shard, { recursive: true }) + await mkdir(outside, { recursive: true }) + await writeFile(sentinel, bytes) + await symlink(sentinel, location.artifact) + + await expectSymlinkRejected({ + root, + key: location.key, + contentDigest, + bytes, + sentinel, + }) + }) }) diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.ts index 544a2fee..d6fa5eb7 100644 --- a/hub/server/integrations/artifact-storage/create-local-storage/index.ts +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.ts @@ -1,48 +1,133 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto" import { constants } from "node:fs" -import { access, link, mkdir, open, readFile, rm } from "node:fs/promises" +import { link, lstat, mkdir, open, realpath, rm, unlink } from "node:fs/promises" import path from "node:path" import type { ArtifactStorage, StoredArtifact } from "#server/integrations/artifact-storage/types" +function isFileSystemError(error: unknown, code: string): boolean { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + error.code === code + ) +} + +function rejectSymbolicLink(): never { + throw new Error("Artifact storage paths cannot contain symbolic links.") +} + function artifactKey(contentDigest: string): string { if (!/^sha256:[a-f0-9]{64}$/.test(contentDigest)) { throw new Error("Artifact content digest is invalid.") } - const digest = contentDigest.replace(/^sha256:/, "") + const digest = contentDigest.slice("sha256:".length) return path.posix.join("sha256", digest.slice(0, 2), digest) } -function storagePath(root: string, key: string): string { - const candidate = path.resolve(root, key) - const resolvedRoot = path.resolve(root) - if (!candidate.startsWith(`${resolvedRoot}${path.sep}`)) { +function parseArtifactKey(key: string) { + const match = /^sha256\/([a-f0-9]{2})\/([a-f0-9]{64})$/.exec(key) + if (!match?.[1] || !match[2] || match[2].slice(0, 2) !== match[1]) { throw new Error("Artifact storage key is invalid.") } - return candidate + return { shard: match[1], digest: match[2] } } function calculateContentDigest(bytes: Uint8Array): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}` } -async function verifyExisting(destination: string, contentDigest: string): Promise { - const expected = Buffer.from(contentDigest.replace(/^sha256:/, ""), "hex") - const actual = createHash("sha256") - .update(await readFile(destination)) - .digest() - if (!timingSafeEqual(expected, actual)) { - throw new Error("Stored artifact content does not match its key.") +function isWithinRoot(root: string, candidate: string): boolean { + return candidate === root || candidate.startsWith(`${root}${path.sep}`) +} + +async function secureRoot(root: string): Promise { + await mkdir(root, { recursive: true, mode: 0o700 }) + const status = await lstat(root) + if (status.isSymbolicLink()) rejectSymbolicLink() + if (!status.isDirectory()) throw new Error("Artifact storage root must be a directory.") + return realpath(root) +} + +async function secureDirectory( + root: string, + parent: string, + name: string, + create: boolean, +): Promise { + const candidate = path.join(parent, name) + if (create) { + try { + await mkdir(candidate, { mode: 0o700 }) + } catch (error) { + if (!isFileSystemError(error, "EEXIST")) throw error + } } + const status = await lstat(candidate) + if (status.isSymbolicLink()) rejectSymbolicLink() + if (!status.isDirectory()) throw new Error("Artifact storage path is not a directory.") + const resolved = await realpath(candidate) + if (!isWithinRoot(root, resolved)) { + throw new Error("Artifact storage path escapes its root.") + } + return resolved } -function isFileSystemError(error: unknown, code: string): boolean { - return ( - error instanceof Error && - "code" in error && - typeof error.code === "string" && - error.code === code +async function resolveArtifactPath(root: string, key: string, createDirectories: boolean) { + const { shard, digest } = parseArtifactKey(key) + const resolvedRoot = await secureRoot(root) + const algorithmDirectory = await secureDirectory( + resolvedRoot, + resolvedRoot, + "sha256", + createDirectories, + ) + const shardDirectory = await secureDirectory( + resolvedRoot, + algorithmDirectory, + shard, + createDirectories, ) + const destination = path.join(shardDirectory, digest) + if (!isWithinRoot(resolvedRoot, destination)) { + throw new Error("Artifact storage path escapes its root.") + } + return { destination, resolvedRoot } +} + +async function validateArtifactPath(resolvedRoot: string, destination: string) { + const status = await lstat(destination) + if (status.isSymbolicLink()) rejectSymbolicLink() + if (!status.isFile()) throw new Error("Artifact storage path is not a file.") + const resolved = await realpath(destination) + if (!isWithinRoot(resolvedRoot, resolved)) { + throw new Error("Artifact storage path escapes its root.") + } +} + +async function openArtifact(resolvedRoot: string, destination: string) { + await validateArtifactPath(resolvedRoot, destination) + return open(destination, constants.O_RDONLY | constants.O_NOFOLLOW) +} + +async function verifyExisting( + resolvedRoot: string, + destination: string, + contentDigest: string, +): Promise { + const file = await openArtifact(resolvedRoot, destination) + try { + const expected = Buffer.from(contentDigest.slice("sha256:".length), "hex") + const actual = createHash("sha256") + .update(await file.readFile()) + .digest() + if (!timingSafeEqual(expected, actual)) { + throw new Error("Stored artifact content does not match its key.") + } + } finally { + await file.close() + } } export function createLocalArtifactStorage(root: string): ArtifactStorage { @@ -55,18 +140,20 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { if (calculateContentDigest(bytes) !== contentDigest) { throw new Error("Artifact bytes do not match the content digest.") } - const destination = storagePath(root, key) - await mkdir(path.dirname(destination), { recursive: true }) + const { destination, resolvedRoot } = await resolveArtifactPath(root, key, true) try { - await access(destination, constants.F_OK) - await verifyExisting(destination, contentDigest) + await verifyExisting(resolvedRoot, destination, contentDigest) return { key, created: false } } catch (error) { if (!isFileSystemError(error, "ENOENT")) throw error } const temporary = `${destination}.${randomUUID()}.tmp` - const file = await open(temporary, "wx", 0o600) + const file = await open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ) try { await file.writeFile(bytes) await file.sync() @@ -74,11 +161,15 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { await file.close() } try { + const resolved = await resolveArtifactPath(root, key, false) + if (resolved.destination !== destination) { + throw new Error("Artifact storage path changed during the write.") + } await link(temporary, destination) return { key, created: true } } catch (error) { if (!isFileSystemError(error, "EEXIST")) throw error - await verifyExisting(destination, contentDigest) + await verifyExisting(resolvedRoot, destination, contentDigest) return { key, created: false } } finally { await rm(temporary, { force: true }) @@ -86,11 +177,23 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { }, async read(key: string): Promise { - return readFile(storagePath(root, key)) + const { destination, resolvedRoot } = await resolveArtifactPath(root, key, false) + const file = await openArtifact(resolvedRoot, destination) + try { + return file.readFile() + } finally { + await file.close() + } }, async remove(key: string): Promise { - await rm(storagePath(root, key), { force: true }) + try { + const { destination, resolvedRoot } = await resolveArtifactPath(root, key, false) + await validateArtifactPath(resolvedRoot, destination) + await unlink(destination) + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) throw error + } }, } } diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts index d139afde..4ed58250 100644 --- a/hub/server/repositories/security-audits/save-audit/index.ts +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -28,6 +28,7 @@ export async function saveSecurityAudit(database: Prisma.TransactionClient, data prototypeId: data.prototypeId ?? null, prototypeVersionId: data.prototypeVersionId ?? null, workloadPublisherId: data.workloadPublisherId ?? null, + workloadCredentialId: data.workloadCredentialId ?? null, targetUserId: data.targetUserId ?? null, githubRepositorySourceId: data.githubRepositorySourceId ?? null, context, diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index 05be2a06..55bbcb22 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -10,6 +10,7 @@ export interface AuditInput { prototypeId?: string | null prototypeVersionId?: string | null workloadPublisherId?: string | null + workloadCredentialId?: string | null targetUserId?: string | null githubRepositorySourceId?: string | null context?: Record @@ -28,6 +29,7 @@ export interface SecurityAuditRecord { prototypeId: string | null prototypeVersionId: string | null workloadPublisherId: string | null + workloadCredentialId: string | null targetUserId: string | null githubRepositorySourceId: string | null context: unknown diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index c648ece9..8fbe4ce9 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -30,6 +30,8 @@ import { listWorkspaces, removePrototypeAccess, removeWorkspaceAccess, + revokePublisherCredential, + setPublisherActive, setPrototypeAccess, setPrototypeAuthorization, setWorkspaceAccess, @@ -159,6 +161,16 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi productMutationLimit, createPublisherCredential.bind(null, authentication), ) + app.patch( + "/api/prototypes/:prototypeId/publishers/:publisherId", + productMutationLimit, + setPublisherActive.bind(null, authentication), + ) + app.delete( + "/api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId", + productMutationLimit, + revokePublisherCredential.bind(null, authentication), + ) app.get( "/api/prototypes/:prototypeId/access/explanation", explainPrototypeAccess.bind(null, authentication), diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts index ef393101..546668f3 100644 --- a/hub/server/routes/product/index.test.ts +++ b/hub/server/routes/product/index.test.ts @@ -40,6 +40,8 @@ function runtime( archivePrototype: vi.fn(), setPrototypeAuthorization: vi.fn(), createPublisherCredential: vi.fn(), + setPublisherActive: vi.fn(), + revokePublisherCredential: vi.fn(), explainPrototypeAccess: vi.fn(), setPrototypeAccess: vi.fn(), removePrototypeAccess: vi.fn(), @@ -234,6 +236,17 @@ describe("product routes", () => { call: "createPublisherCredential", body: { name: "Local publisher", actions: ["publish"] }, }, + { + method: "PATCH", + path: "/api/prototypes/prototype/publishers/publisher", + call: "setPublisherActive", + body: { active: false }, + }, + { + method: "DELETE", + path: "/api/prototypes/prototype/publishers/publisher/credentials/credential", + call: "revokePublisherCredential", + }, { method: "POST", path: "/api/prototypes/prototype/archive", diff --git a/hub/server/routes/product/index.ts b/hub/server/routes/product/index.ts index bebe917d..2126c361 100644 --- a/hub/server/routes/product/index.ts +++ b/hub/server/routes/product/index.ts @@ -165,6 +165,34 @@ export async function createPublisherCredential( response.status(201).json(result) } +export async function setPublisherActive( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const publisher = await authentication.prototypeAuthorization.setPublisherActive( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "publisherId"), + request.body, + ) + response.json({ publisher }) +} + +export async function revokePublisherCredential( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await authentication.prototypeAuthorization.revokePublisherCredential( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "publisherId"), + parameter(request, "credentialId"), + ) + response.status(204).end() +} + export async function explainPrototypeAccess( authentication: AuthenticationRoutes, request: Request, From 4e7b84a1f98e5b323eefc782e547a44f7d1a2c4c Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 16:54:48 -0700 Subject: [PATCH 30/56] fix(hub): recheck publisher authorization under lock Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/artifact-ingestion.md | 2 + .../create-service/index.db.test.ts | 159 ++++++++++++++++++ .../create-service/index.ts | 58 ++++++- 3 files changed, 214 insertions(+), 5 deletions(-) diff --git a/hub/docs/artifact-ingestion.md b/hub/docs/artifact-ingestion.md index 8f79582c..13250fad 100644 --- a/hub/docs/artifact-ingestion.md +++ b/hub/docs/artifact-ingestion.md @@ -15,6 +15,8 @@ Hub returns a bearer credential once and stores only its SHA-256 digest. The cre Browser users provision credentials through `POST /api/prototypes/:prototypeId/publishers`. This requires a fresh prototype-administrator session and returns the raw token only in that response. The same administrator may temporarily disable or re-enable a publisher with `PATCH /api/prototypes/:prototypeId/publishers/:publisherId`, or permanently revoke one credential with `DELETE /api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId`. Hub audits each state change. Re-enabling a publisher does not restore a revoked credential. +Publication, promotion, publisher state changes, and credential revocation use the same prototype lock. Publication and promotion re-read credential expiry, revocation, scope, action, token digest, and publisher state after acquiring that lock. A revocation or disable that locks first prevents an already-started workload request from committing; a workload operation that locks first completes before the administrator mutation. + Archiving a prototype blocks new publishers, publication, publisher re-enablement, and `current` or `preview` promotion. Administrators may still disable publishers and revoke credentials after archival. Existing versions and publication status remain readable through their authorized interfaces. ## Manifest contract diff --git a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts index a25e1418..f725e003 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts @@ -79,6 +79,56 @@ async function database() { } } +function deferred() { + let resolvePromise!: () => void + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} + +async function waitForBlockedDatabaseQuery(database: PrismaClient, queryPattern: string) { + await vi.waitFor( + async () => { + const [result] = await database.$queryRaw<{ blocked: boolean }[]>` + SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' + AND query LIKE ${queryPattern} + ) AS blocked + ` + expect(result?.blocked).toBe(true) + }, + { interval: 20, timeout: 5_000 }, + ) +} + +async function holdPublisherAuthorizationRow( + database: PrismaClient, + target: { type: "credential" | "publisher"; id: string }, +) { + const released = deferred() + const locked = deferred() + const holder = database.$transaction(async (transaction) => { + if (target.type === "credential") { + await transaction.$queryRaw` + SELECT "id" FROM "workload_credentials" WHERE "id" = ${target.id}::uuid FOR UPDATE + ` + } else { + await transaction.$queryRaw` + SELECT "id" FROM "workload_publishers" WHERE "id" = ${target.id}::uuid FOR UPDATE + ` + } + locked.resolve() + await released.promise + }) + await locked.promise + return { holder, release: released.resolve } +} + async function createPrototype(prisma: PrismaClient) { const user = await prisma.user.create({ data: { displayName: "Publisher owner" } }) const workspace = await prisma.workspace.create({ @@ -490,6 +540,115 @@ describe("createArtifactPublishingService() database behavior", () => { ).toBe(2) }) + it.each([ + { mutation: "revoke", operation: "publish" }, + { mutation: "disable", operation: "publish" }, + { mutation: "revoke", operation: "promote" }, + { mutation: "disable", operation: "promote" }, + ] as const)( + "rejects an already-started $operation after an administrator wins the $mutation race", + async ({ mutation, operation }) => { + const context = await database() + const sessionToken = `${mutation}-${operation}-session` + const { authorization, prototype, provisioned, workloadAuthorization } = + await provisionPublisher(context.prisma, sessionToken) + const published = + operation === "promote" + ? await context.service.publish( + workloadAuthorization, + prototype.id, + `${mutation}-promotion-source`, + publicationInput(prototype.id, artifact(`${mutation}-promotion-source`)), + ) + : null + const version = published ? publishedVersion(published) : null + const authorizationRow = + mutation === "revoke" + ? { type: "credential" as const, id: provisioned.credential.id } + : { type: "publisher" as const, id: provisioned.publisher.id } + const authorizationTable = + mutation === "revoke" ? "workload_credentials" : "workload_publishers" + const before = { + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + channels: await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + orderBy: { name: "asc" }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { + prototypeId: prototype.id, + event: { in: ["prototype.version.publish", "prototype.channel.promote"] }, + }, + }), + } + const rowLock = await holdPublisherAuthorizationRow(context.prisma, authorizationRow) + + const administratorMutation = + mutation === "revoke" + ? authorization.revokePublisherCredential( + sessionToken, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ) + : authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: false, + }) + await waitForBlockedDatabaseQuery(context.prisma, `%${authorizationTable}%`) + + const workloadResult = ( + operation === "publish" + ? context.service.publish( + workloadAuthorization, + prototype.id, + `${mutation}-concurrent-publish`, + publicationInput(prototype.id, artifact(`${mutation}-concurrent-publish`)), + ) + : context.service.promote(workloadAuthorization, prototype.id, "current", { + versionId: version!.id, + }) + ).then( + () => ({ error: null }), + (error: unknown) => ({ error }), + ) + await waitForBlockedDatabaseQuery(context.prisma, "%prototypes%FOR UPDATE%") + + rowLock.release() + await rowLock.holder + await administratorMutation + + expect((await workloadResult).error).toMatchObject({ + status: 403, + code: "publisher_access_denied", + }) + expect({ + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + channels: await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + orderBy: { name: "asc" }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { + prototypeId: prototype.id, + event: { in: ["prototype.version.publish", "prototype.channel.promote"] }, + }, + }), + }).toEqual(before) + }, + ) + it("enforces publish and read-status action scopes independently", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) diff --git a/hub/server/domains/artifact-publishing/create-service/index.ts b/hub/server/domains/artifact-publishing/create-service/index.ts index 39ad72d0..5b48569b 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.ts @@ -155,6 +155,12 @@ export function createArtifactPublishingService({ database: PrismaClient storage: ArtifactStorage }): ArtifactPublishingService { + type PublisherAuthentication = { + credentialId: string + publisherId: string + tokenDigest: string + } + async function requireUnarchivedPrototype(prototypeId: string) { const prototype = await database.prototype.findUnique({ where: { id: prototypeId }, @@ -173,6 +179,7 @@ export function createArtifactPublishingService({ error(401, "publisher_authentication_required", "Publisher authentication failed.") } const [, token, credentialId] = match + const tokenDigest = digestSecret(token) const credential = await database.workloadCredential.findUnique({ where: { id: credentialId }, include: { workloadPublisher: true }, @@ -184,7 +191,36 @@ export function createArtifactPublishingService({ !credential.workloadPublisher.active || credential.workloadPublisher.prototypeId !== prototypeId || !credential.workloadPublisher.actions.includes(action) || - !secretsMatch(credential.tokenDigest, digestSecret(token)) + !secretsMatch(credential.tokenDigest, tokenDigest) + ) { + error(403, "publisher_access_denied", "Publisher access is denied.") + } + return { + credentialId: credential.id, + publisherId: credential.workloadPublisher.id, + tokenDigest, + } + } + + async function requireCurrentPublisherAuthorization( + transaction: Prisma.TransactionClient, + authentication: PublisherAuthentication, + prototypeId: string, + action: PublisherAction, + ) { + const credential = await transaction.workloadCredential.findUnique({ + where: { id: authentication.credentialId }, + include: { workloadPublisher: true }, + }) + if ( + !credential || + credential.revokedAt || + (credential.expiresAt && credential.expiresAt <= new Date()) || + !credential.workloadPublisher.active || + credential.workloadPublisher.id !== authentication.publisherId || + credential.workloadPublisher.prototypeId !== prototypeId || + !credential.workloadPublisher.actions.includes(action) || + !secretsMatch(credential.tokenDigest, authentication.tokenDigest) ) { error(403, "publisher_access_denied", "Publisher access is denied.") } @@ -193,7 +229,7 @@ export function createArtifactPublishingService({ return { async publish(authorization, prototypeId, idempotencyKey, input) { - const publisher = await authenticate(authorization, prototypeId, "publish") + const authentication = await authenticate(authorization, prototypeId, "publish") await requireUnarchivedPrototype(prototypeId) const parsed = publishSchema.safeParse(input) if (!parsed.success || parsed.data.manifest.prototypeId !== prototypeId) { @@ -217,6 +253,12 @@ export function createArtifactPublishingService({ return database.$transaction(async (transaction) => { await lockPrototype(transaction, prototypeId) + const publisher = await requireCurrentPublisherAuthorization( + transaction, + authentication, + prototypeId, + "publish", + ) const prototype = await transaction.prototype.findUnique({ where: { id: prototypeId } }) if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") if (prototype.archivedAt) { @@ -325,11 +367,11 @@ export function createArtifactPublishingService({ }, async getStatus(authorization, prototypeId, idempotencyKey) { - const publisher = await authenticate(authorization, prototypeId, "read-status") + const authentication = await authenticate(authorization, prototypeId, "read-status") const publication = await database.artifactPublication.findUnique({ where: { workloadPublisherId_idempotencyKeyDigest: { - workloadPublisherId: publisher.id, + workloadPublisherId: authentication.publisherId, idempotencyKeyDigest: digestSecret(parseIdempotencyKey(idempotencyKey)), }, }, @@ -345,7 +387,7 @@ export function createArtifactPublishingService({ }, async promote(authorization, prototypeId, channel, input) { - const publisher = await authenticate(authorization, prototypeId, "publish") + const authentication = await authenticate(authorization, prototypeId, "publish") const parsedChannel = channelSchema.safeParse(channel) const parsed = promotionSchema.safeParse(input) if (!parsedChannel.success || !parsed.success) { @@ -353,6 +395,12 @@ export function createArtifactPublishingService({ } return database.$transaction(async (transaction) => { await lockPrototype(transaction, prototypeId) + const publisher = await requireCurrentPublisherAuthorization( + transaction, + authentication, + prototypeId, + "publish", + ) const prototype = await transaction.prototype.findUnique({ where: { id: prototypeId } }) if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") if (prototype.archivedAt) { From 02eedd047ae30ddbbe15f1e66eaf06fc4ccc8d51 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 14:01:45 -0700 Subject: [PATCH 31/56] fix(hub): address artifact ingestion review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 3 +- hub/docs/artifact-ingestion.md | 12 +- hub/docs/logging.md | 2 +- hub/docs/prototype-authorization.md | 1 + hub/server/create-app/index.ts | 16 +- .../create-service/index.db.test.ts | 188 ++++++++++++++++-- .../create-service/index.ts | 73 ++++--- .../create-runtime/index.test.ts | 36 ++++ .../authentication/create-runtime/index.ts | 3 +- .../create-service/index.db.test.ts | 66 ++++++ .../create-service/index.ts | 43 +++- .../create-service/types.ts | 1 + .../create-local-storage/index.test.ts | 60 +++++- .../create-local-storage/index.ts | 34 ++-- hub/server/routes/index.ts | 10 +- hub/server/routes/product/index.test.ts | 28 +++ hub/server/routes/product/index.ts | 13 ++ hub/server/routes/publishing.test.ts | 70 +++++++ 18 files changed, 571 insertions(+), 88 deletions(-) create mode 100644 hub/server/domains/authentication/create-runtime/index.test.ts diff --git a/hub/.env.example b/hub/.env.example index 105360e7..13cd76bb 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -16,7 +16,8 @@ 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 -# Local reference artifact storage. Production deployments should mount durable storage here. +# Local reference artifact storage. The default is ~/.replica/artifacts, outside the checkout. +# Production deployments should mount durable storage here. # HUB_ARTIFACT_STORAGE_PATH=/var/lib/replica/artifacts # GitHub user authorization is optional until both values are configured. diff --git a/hub/docs/artifact-ingestion.md b/hub/docs/artifact-ingestion.md index 13250fad..03a663b1 100644 --- a/hub/docs/artifact-ingestion.md +++ b/hub/docs/artifact-ingestion.md @@ -4,16 +4,16 @@ Hub accepts one provider-neutral artifact contract from workload producers. GitH ## Identity and access -A prototype administrator creates a workload publisher with one or both actions: +A prototype administrator creates a workload publisher with `publish` and may also grant `read-status`: | Action | Authority | | ------------- | ---------------------------------------------------------------------------- | | `publish` | Publish an artifact and move the prototype's `current` or `preview` channel. | -| `read-status` | Read the result associated with that publisher's idempotency key. | +| `read-status` | Read the result associated with that publisher's idempotency key. This action supplements `publish`; a status-only publisher would have no publication to read. | Hub returns a bearer credential once and stores only its SHA-256 digest. The credential is scoped to one prototype through its workload publisher. It cannot create a browser session, view a prototype through user APIs, administer access, or publish to another prototype. A producer's source URL, revision, ref, pipeline, and run ID describe where a build came from; they never grant access. -Browser users provision credentials through `POST /api/prototypes/:prototypeId/publishers`. This requires a fresh prototype-administrator session and returns the raw token only in that response. The same administrator may temporarily disable or re-enable a publisher with `PATCH /api/prototypes/:prototypeId/publishers/:publisherId`, or permanently revoke one credential with `DELETE /api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId`. Hub audits each state change. Re-enabling a publisher does not restore a revoked credential. +Browser users provision credentials through `POST /api/prototypes/:prototypeId/publishers`. This requires a fresh prototype-administrator session and returns the raw token only in that response. `GET /api/prototypes/:prototypeId/publishers` returns administrator-only publisher and credential metadata without token digests or raw tokens, so another administrator can discover and revoke existing access. An administrator may temporarily disable or re-enable a publisher with `PATCH /api/prototypes/:prototypeId/publishers/:publisherId`, or permanently revoke one credential with `DELETE /api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId`. Hub audits each state change. Re-enabling a publisher does not restore a revoked credential. Publication, promotion, publisher state changes, and credential revocation use the same prototype lock. Publication and promotion re-read credential expiry, revocation, scope, action, token digest, and publisher state after acquiring that lock. A revocation or disable that locks first prevents an already-started workload request from committing; a workload operation that locks first completes before the administrator mutation. @@ -49,11 +49,13 @@ Archiving a prototype blocks new publishers, publication, publisher re-enablemen } ``` -The manifest is closed to unknown fields. Hub bounds every string, accepts only HTTP or HTTPS source URLs without embedded usernames or passwords, limits decoded artifacts to 8 MiB in this reference layer, verifies the decoded size, and recalculates the SHA-256 digest before storing bytes. The manifest prototype ID must match the route. +The manifest and its nested objects are closed to unknown fields. Hub bounds every string, accepts only HTTP or HTTPS source URLs without embedded usernames, passwords, query strings, or fragments, limits decoded artifacts to 8 MiB in this reference layer, verifies canonical base64, checks the decoded size, and recalculates the SHA-256 digest before storing bytes. The manifest prototype ID must match the route. + +Publishing requests are rate-limited before JSON parsing. The version-upload route alone accepts the bounded upload-sized JSON body; status and promotion retain the API's default JSON limit. ## Storage boundary -`ArtifactStorage` separates ingestion from byte storage. The reference adapter writes content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`, defaulting to `hub/.data/artifacts` for local use. Database rows store only the storage key and validated metadata, never the artifact bytes. +`ArtifactStorage` separates ingestion from byte storage. The reference adapter writes content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`, defaulting to `~/.replica/artifacts` outside the source checkout and Vite development root. Database rows store only the storage key and validated metadata, never the artifact bytes. The local adapter is suitable for one Hub process with a durable mounted filesystem. It rejects symbolic-link roots, shards, and artifact paths, resolves directories under the configured root, and opens artifact files without following a final symbolic link. Operators must keep the storage directory writable only by the Hub workload; the local adapter does not provide replication, remote durability, encryption-key management, hostile concurrent filesystem isolation, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. diff --git a/hub/docs/logging.md b/hub/docs/logging.md index e30872e6..78b39aea 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,7 +30,7 @@ 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. -`createApp()` installs `omitRequestLogPayload` for authentication, session, administration, product, and publishing paths after request logging but before JSON parsing. This ordering covers parser failures as well as later origin checks, rate limits, and handlers: +`createApp()` installs `omitRequestLogPayload` for authentication, session, administration, product, and publishing paths after request logging but before JSON parsing. Publishing rate limits also run before parsing, and only the artifact-upload route receives the larger bounded parser. This ordering covers parser failures as well as later origin checks and handlers: ```ts import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 56fabb6b..31dec895 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -77,6 +77,7 @@ The product API uses: - `GET /api/prototypes/:prototypeId/versions` - `GET /api/prototypes/:prototypeId/versions/:versionId` - `POST /api/prototypes/:prototypeId/publishers` +- `GET /api/prototypes/:prototypeId/publishers` - `PATCH /api/prototypes/:prototypeId/publishers/:publisherId` - `DELETE /api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId` diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index 947ade16..f58b96b0 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -1,13 +1,14 @@ import express from "express" import type { Logger } from "pino" +import { maximumPublishingJsonBytes } from "../domains/artifact-publishing/create-service/constants.ts" +import { createRateLimit } from "../integrations/express/create-rate-limit/index.ts" import { handleError } from "../integrations/express/handle-error/index.ts" import { respondNotFound } from "../integrations/express/respond-not-found/index.ts" import { createRequestLogger } from "../integrations/pino/create-request-logger/index.ts" import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" import { routes } from "../routes/index.ts" import type { AuthenticationRoutes } from "../routes/types.ts" -import { maximumPublishingJsonBytes } from "../domains/artifact-publishing/create-service/constants.ts" /** Give every endpoint the same request logging and safe error responses. */ export function createApp({ @@ -31,7 +32,18 @@ export function createApp({ omitRequestLogPayload, ) app.use("/api/github/webhooks", express.raw({ type: "application/json", limit: "1mb" })) - app.use("/api/publishing", express.json({ limit: maximumPublishingJsonBytes })) + app.use( + "/api/publishing", + createRateLimit({ + limit: 60, + windowMilliseconds: 5 * 60_000, + message: "Too many publishing requests.", + }), + ) + app.use( + "/api/publishing/prototypes/:prototypeId/versions", + express.json({ limit: maximumPublishingJsonBytes }), + ) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts index f725e003..79bb7303 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts @@ -1,8 +1,10 @@ import { createHash, randomUUID } from "node:crypto" +import { readFile } from "node:fs/promises" import { describe, expect, it, onTestFinished, vi } from "vitest" import { createArtifactPublishingService } from "#server/domains/artifact-publishing/create-service" +import { maximumArtifactBytes } from "#server/domains/artifact-publishing/create-service/constants" import type { ArtifactManifest } from "#server/domains/artifact-publishing/create-service/types" import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" @@ -12,7 +14,10 @@ import { digestSecret } from "#server/utils/digest-secret" import type { PrismaClient } from "../../../generated/prisma/client.ts" function artifact(content: string) { - const bytes = Buffer.from(content) + return artifactBytes(Buffer.from(content)) +} + +function artifactBytes(bytes: Buffer) { return { bytes, base64: bytes.toString("base64"), @@ -47,13 +52,13 @@ function manifest( } } -async function database() { +async function database(storageOverride?: ArtifactStorage) { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") onTestFinished(() => prisma.$disconnect()) const stored = new Map() - const storage: ArtifactStorage = { + const storage: ArtifactStorage = storageOverride ?? { put(digest, bytes) { const key = digest.replace(":", "/") const created = !stored.has(key) @@ -290,6 +295,26 @@ describe("createArtifactPublishingService() database behavior", () => { manifest: { ...valid.manifest, unrecognized: true }, }), }, + { + name: "unknown artifact field", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + artifact: { ...valid.manifest.artifact, unrecognized: true }, + }, + }), + }, + { + name: "unknown provenance field", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { ...valid.manifest.provenance, unrecognized: true }, + }, + }), + }, { name: "unsupported schema version", change: (valid: ReturnType) => ({ @@ -307,6 +332,16 @@ describe("createArtifactPublishingService() database behavior", () => { }, }), }, + { + name: "malformed provenance URL", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { ...valid.manifest.provenance, sourceUrl: "https://[invalid" }, + }, + }), + }, { name: "provenance URL username", change: (valid: ReturnType) => ({ @@ -333,6 +368,32 @@ describe("createArtifactPublishingService() database behavior", () => { }, }), }, + { + name: "provenance URL query", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { + ...valid.manifest.provenance, + sourceUrl: "https://ci.example/runs/123?token=private", + }, + }, + }), + }, + { + name: "provenance URL fragment", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { + ...valid.manifest.provenance, + sourceUrl: "https://ci.example/runs/123#private", + }, + }, + }), + }, { name: "different prototype identifier", change: (valid: ReturnType) => ({ @@ -360,6 +421,50 @@ describe("createArtifactPublishingService() database behavior", () => { expect(await context.prisma.prototypeVersion.count()).toBe(0) }) + it("publishes a valid artifact at the decoded size limit", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifactBytes(Buffer.alloc(maximumArtifactBytes, 0x61)) + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "maximum-artifact", + publicationInput(prototype.id, publishedArtifact), + ), + ).resolves.toMatchObject({ replayed: false }) + }) + + it("rejects noncanonical base64 as an invalid artifact instead of an oversized artifact", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const input = publicationInput(prototype.id, artifact("base64")) + input.artifactBase64 = "AB==" + + await expect( + context.service.publish(credential.authorization, prototype.id, "invalid-base64", input), + ).rejects.toMatchObject({ status: 422, code: "invalid_artifact" }) + }) + + it("rejects malformed workload credential identifiers before querying the database", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const findCredential = vi.spyOn(context.prisma.workloadCredential, "findUnique") + + await expect( + context.service.publish( + `Bearer rpl_pub_00000000-0000-0000-0000-00000000000-.${"t".repeat(43)}`, + prototype.id, + "malformed-credential", + publicationInput(prototype.id, artifact("malformed-credential")), + ), + ).rejects.toMatchObject({ status: 401, code: "publisher_authentication_required" }) + expect(findCredential).not.toHaveBeenCalled() + }) + it("rejects an artifact whose decoded size differs from the manifest", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) @@ -772,18 +877,19 @@ describe("createArtifactPublishingService() database behavior", () => { expect(results.map(({ replayed }) => replayed)).toEqual([false, false]) expect(versions.map(({ sequence }) => sequence)).toEqual([1, 2]) - await context.prisma.$executeRawUnsafe(` - CREATE FUNCTION reject_test_prototype_version_mutation() RETURNS trigger AS $$ - BEGIN - RAISE EXCEPTION 'prototype versions are immutable'; - END; - $$ LANGUAGE plpgsql; - `) - await context.prisma.$executeRawUnsafe(` - CREATE TRIGGER test_prototype_versions_immutable - BEFORE UPDATE OR DELETE ON prototype_versions - FOR EACH ROW EXECUTE FUNCTION reject_test_prototype_version_mutation(); - `) + const migration = await readFile( + new URL( + "../../../../schema/migrations/20261027230000_artifact_ingestion/migration.sql", + import.meta.url, + ), + "utf8", + ) + const immutableVersionStart = migration.indexOf( + "CREATE FUNCTION reject_prototype_version_mutation()", + ) + expect(immutableVersionStart).toBeGreaterThanOrEqual(0) + const immutableVersionSql = migration.slice(immutableVersionStart) + await context.prisma.$executeRawUnsafe(immutableVersionSql) try { await expect( context.prisma.prototypeVersion.update({ @@ -796,12 +902,56 @@ describe("createArtifactPublishingService() database behavior", () => { ).rejects.toThrow(/prototype versions are immutable/i) } finally { await context.prisma.$executeRawUnsafe( - "DROP TRIGGER test_prototype_versions_immutable ON prototype_versions", - ) - await context.prisma.$executeRawUnsafe( - "DROP FUNCTION reject_test_prototype_version_mutation()", + "DROP TRIGGER prototype_versions_immutable ON prototype_versions", ) + await context.prisma.$executeRawUnsafe("DROP FUNCTION reject_prototype_version_mutation()") + } + }) + + it("rejects an already-started publication when archive acquires the prototype lock first", async () => { + const putStarted = deferred() + const releasePut = deferred() + const storage: ArtifactStorage = { + async put(digest) { + putStarted.resolve() + await releasePut.promise + return { key: digest.replace(":", "/"), created: true } + }, + read: vi.fn(), + remove: vi.fn(), } + const context = await database(storage) + const sessionToken = "archive-race-session" + const { authorization, prototype, workloadAuthorization } = await provisionPublisher( + context.prisma, + sessionToken, + ) + const publication = context.service.publish( + workloadAuthorization, + prototype.id, + "archive-race", + publicationInput(prototype.id, artifact("archive-race")), + ) + await putStarted.promise + + await authorization.archivePrototype(sessionToken, prototype.id) + releasePut.resolve() + + await expect(publication).rejects.toMatchObject({ + status: 409, + code: "prototype_archived", + }) + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.artifactPublication.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + }), + ).toBe(0) }) it("promotes and rolls back a channel by moving one atomic pointer", async () => { diff --git a/hub/server/domains/artifact-publishing/create-service/index.ts b/hub/server/domains/artifact-publishing/create-service/index.ts index 5b48569b..c0ae530c 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.ts @@ -14,36 +14,44 @@ import type { ArtifactPublishingService, PrototypeChannelName, PublisherAction } import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" const boundedIdentifier = z.string().trim().min(1).max(255) +function isSafeProvenanceUrl(value: string): boolean { + try { + const url = new URL(value) + return ( + ["http:", "https:"].includes(url.protocol) && + url.username.length === 0 && + url.password.length === 0 && + url.search.length === 0 && + url.hash.length === 0 + ) + } catch { + return false + } +} + const manifestSchema = z .object({ schemaVersion: z.literal(1), prototypeId: z.uuid(), - artifact: z.object({ - format: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), - version: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/), - digest: z.string().regex(/^sha256:[a-f0-9]{64}$/), - sizeBytes: z.number().int().min(0).max(maximumArtifactBytes), - builtAt: z.iso.datetime({ offset: true }), - }), - provenance: z.object({ - provider: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), - sourceUrl: z - .url() - .max(2_048) - .refine((value) => { - const url = new URL(value) - return ( - ["http:", "https:"].includes(url.protocol) && - url.username.length === 0 && - url.password.length === 0 - ) - }) - .optional(), - revision: boundedIdentifier.optional(), - ref: boundedIdentifier.optional(), - pipeline: boundedIdentifier.optional(), - runId: boundedIdentifier.optional(), - }), + artifact: z + .object({ + format: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), + version: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/), + digest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + sizeBytes: z.number().int().min(0).max(maximumArtifactBytes), + builtAt: z.iso.datetime({ offset: true }), + }) + .strict(), + provenance: z + .object({ + provider: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), + sourceUrl: z.string().max(2_048).refine(isSafeProvenanceUrl).optional(), + revision: boundedIdentifier.optional(), + ref: boundedIdentifier.optional(), + pipeline: boundedIdentifier.optional(), + runId: boundedIdentifier.optional(), + }) + .strict(), }) .strict() const publishSchema = z @@ -61,7 +69,8 @@ const idempotencyKeySchema = z .min(1) .max(200) .regex(/^[\x21-\x7e]+$/) -const authorizationSchema = /^Bearer (rpl_pub_([0-9a-f-]{36})\.([A-Za-z0-9_-]{43}))$/ +const authorizationSchema = + /^Bearer (rpl_pub_([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.([A-Za-z0-9_-]{43}))$/i function error(status: number, code: string, message: string): never { throw new AuthenticationError(status, code, message) @@ -82,14 +91,14 @@ function parseIdempotencyKey(value: string): string { } function parseArtifactBase64(value: string): Uint8Array { - if ( - value.length % 4 !== 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) - ) { + if (value.length % 4 !== 0) { error(422, "invalid_artifact", "The artifact encoding is invalid.") } const bytes = Buffer.from(value, "base64") - if (bytes.length > maximumArtifactBytes || bytes.toString("base64") !== value) { + if (bytes.toString("base64") !== value) { + error(422, "invalid_artifact", "The artifact encoding is invalid.") + } + if (bytes.length > maximumArtifactBytes) { error(413, "artifact_too_large", "The artifact exceeds the maximum size.") } return bytes diff --git a/hub/server/domains/authentication/create-runtime/index.test.ts b/hub/server/domains/authentication/create-runtime/index.test.ts new file mode 100644 index 00000000..1fdf2b99 --- /dev/null +++ b/hub/server/domains/authentication/create-runtime/index.test.ts @@ -0,0 +1,36 @@ +import os from "node:os" +import path from "node:path" + +import { describe, expect, it, vi } from "vitest" + +const { createLocalArtifactStorage } = vi.hoisted(() => ({ + createLocalArtifactStorage: vi.fn((root: string) => { + void root + return { + put: vi.fn(), + read: vi.fn(), + remove: vi.fn(), + } + }), +})) + +vi.mock("#server/integrations/artifact-storage/create-local-storage", () => ({ + createLocalArtifactStorage, +})) +vi.mock("#server/integrations/prisma", () => ({ prisma: {} })) + +import { createAuthenticationRuntime } from "#server/domains/authentication/create-runtime" + +describe("createAuthenticationRuntime()", () => { + it("keeps default artifact storage outside the checkout", async () => { + await createAuthenticationRuntime({}) + + expect(createLocalArtifactStorage).toHaveBeenCalledWith( + path.join(os.homedir(), ".replica", "artifacts"), + ) + const storagePath = createLocalArtifactStorage.mock.calls[0]?.[0] + expect(storagePath).toBeTypeOf("string") + if (!storagePath) throw new Error("Expected an artifact storage path") + expect(path.relative(process.cwd(), storagePath)).toMatch(/^\.\./) + }) +}) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index e9cf2a13..9dce0c19 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -1,4 +1,5 @@ import path from "node:path" +import os from "node:os" import { createAdmissionPolicy } from "#server/domains/authentication/create-admission-policy" import { createArtifactPublishingService } from "#server/domains/artifact-publishing/create-service" @@ -93,7 +94,7 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv trustedProxyHops: readTrustedProxyHops(environment), } const artifactStoragePath = path.resolve( - environment.HUB_ARTIFACT_STORAGE_PATH ?? path.join(process.cwd(), ".data", "artifacts"), + environment.HUB_ARTIFACT_STORAGE_PATH ?? path.join(os.homedir(), ".replica", "artifacts"), ) return { administration: createAdministrationService({ store }), diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 267ba5b1..741b862f 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -963,6 +963,72 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ).toBe(4) }) + it("requires publish permission when provisioning a publisher", async () => { + const { prisma, service, store } = await database() + const token = "publisher-action-token" + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-action", + token, + }) + + await expect( + service.createPublisherCredential(token, prototype.id, { + name: "Status-only publisher", + actions: ["read-status"], + }), + ).rejects.toMatchObject({ status: 422, code: "invalid_workload_publisher" }) + expect(await prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } })).toBe(0) + }) + + it("lists bounded publisher and credential metadata without reusable secrets", async () => { + const { prisma, service, store } = await database() + const token = "publisher-list-token" + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-list", + token, + }) + const first = (await service.createPublisherCredential(token, prototype.id, { + name: "First publisher", + actions: ["publish"], + })) as { credential: { token: string } } + const second = (await service.createPublisherCredential(token, prototype.id, { + name: "Second publisher", + actions: ["publish", "read-status"], + })) as { credential: { token: string } } + + const firstPage = (await service.listPublishers(token, prototype.id, { + limit: 1, + })) as { + items: { id: string; credentials: { id: string }[] }[] + nextCursor: string | null + } + const secondPage = (await service.listPublishers(token, prototype.id, { + limit: 1, + cursor: firstPage.nextCursor, + })) as { + items: { id: string; credentials: { id: string }[] }[] + nextCursor: string | null + } + const serialized = JSON.stringify([firstPage, secondPage]) + const tokenDigests = await prisma.workloadCredential.findMany({ + select: { tokenDigest: true }, + }) + + expect(firstPage.items).toHaveLength(1) + expect(firstPage.nextCursor).toBe(firstPage.items[0]?.id) + expect(secondPage.items).toHaveLength(1) + expect(secondPage.nextCursor).toBeNull() + expect(new Set([...firstPage.items, ...secondPage.items].map(({ id }) => id)).size).toBe(2) + expect(serialized).not.toContain(first.credential.token) + expect(serialized).not.toContain(second.credential.token) + for (const { tokenDigest } of tokenDigests) expect(serialized).not.toContain(tokenDigest) + expect(serialized).not.toContain("tokenDigest") + }) + it("does not apply publisher or credential lifecycle operations across prototypes", async () => { const { prisma, service, store } = await database() const token = "publisher-scope-token" diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 5743b0a5..950b24bd 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -29,7 +29,7 @@ const publisherSchema = z .array(z.enum(["publish", "read-status"])) .min(1) .max(2) - .refine((actions) => new Set(actions).size === actions.length), + .refine((actions) => new Set(actions).size === actions.length && actions.includes("publish")), expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), }) .strict() @@ -1006,6 +1006,47 @@ export function createPrototypeAuthorizationService({ }) }, + async listPublishers(sessionToken, prototypeId, query) { + const { cursor, limit } = parsePage(query) + return store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) + const { decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + baseline, + ) + requireDecision(decision, "administrator") + const items = await transaction.workloadPublisher.findMany({ + where: { prototypeId }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + select: { + id: true, + name: true, + actions: true, + active: true, + createdAt: true, + updatedAt: true, + credentials: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + expiresAt: true, + revokedAt: true, + createdAt: true, + }, + }, + }, + }) + return pageResult(items, limit) + }) + }, + async setPublisherActive(sessionToken, prototypeId, publisherId, input) { const parsed = publisherStateSchema.safeParse(input) if (!parsed.success) { diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index 21cfb109..e578434d 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -29,6 +29,7 @@ export interface PrototypeAuthorizationService { prototypeId: string, input: unknown, ): Promise + listPublishers(sessionToken: string, prototypeId: string, query: unknown): Promise> setPublisherActive( sessionToken: string, prototypeId: string, diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts index e72e9f55..d0273e2a 100644 --- a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts @@ -1,8 +1,8 @@ import { createHash, randomUUID } from "node:crypto" -import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises" +import { mkdir, open, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises" import path from "node:path" -import { describe, expect, it, onTestFinished } from "vitest" +import { describe, expect, it, onTestFinished, vi } from "vitest" import { createLocalArtifactStorage } from "#server/integrations/artifact-storage/create-local-storage" @@ -117,6 +117,62 @@ describe("createLocalArtifactStorage()", () => { expect(Buffer.from(await storage.read(results[0].key))).toEqual(bytes) }) + it.each(["writeFile", "sync"] as const)( + "removes its temporary file when %s fails", + async (operation) => { + const root = storageRoot() + const bytes = Buffer.from(`failed artifact ${operation}`) + const contentDigest = digest(bytes) + const location = artifactPath(root, contentDigest) + await mkdir(location.shard, { recursive: true }) + const probe = await open(path.join(root, "probe"), "w") + await probe.close() + const failure = vi + .spyOn(Object.getPrototypeOf(probe) as typeof probe, operation) + .mockRejectedValue(new Error(`${operation} failed`)) + + await expect(createLocalArtifactStorage(root).put(contentDigest, bytes)).rejects.toThrow( + `${operation} failed`, + ) + + expect((await readdir(location.shard)).filter((name) => name.endsWith(".tmp"))).toEqual([]) + failure.mockRestore() + }, + ) + + it("waits for a failed read before closing the artifact file", async () => { + const root = storageRoot() + const storage = createLocalArtifactStorage(root) + const bytes = Buffer.from("failed artifact read") + const stored = await storage.put(digest(bytes), bytes) + const probe = await open(path.join(root, "probe"), "w") + const fileHandlePrototype = Object.getPrototypeOf(probe) as typeof probe + await probe.close() + let rejectRead!: (error: Error) => void + let artifactFile: typeof probe | undefined + function captureArtifactFile(file: typeof probe) { + artifactFile = file + } + const readFailure = new Promise((_resolve, reject) => { + rejectRead = reject + }) + const readFile = vi.spyOn(fileHandlePrototype, "readFile").mockImplementation(function ( + this: typeof probe, + ) { + captureArtifactFile(this) + return readFailure + }) + + const read = storage.read(stored.key) + await vi.waitFor(() => expect(artifactFile).toBeDefined()) + await expect(artifactFile!.stat()).resolves.toBeDefined() + rejectRead(new Error("read failed")) + + await expect(read).rejects.toThrow("read failed") + await expect(artifactFile!.stat()).rejects.toMatchObject({ code: "EBADF" }) + readFile.mockRestore() + }) + it("rejects a symbolic-link storage root without reading, replaying, or removing outside content", async () => { const root = storageRoot() const outside = storageRoot() diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.ts index d6fa5eb7..d2827ca6 100644 --- a/hub/server/integrations/artifact-storage/create-local-storage/index.ts +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.ts @@ -155,22 +155,24 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { 0o600, ) try { - await file.writeFile(bytes) - await file.sync() - } finally { - await file.close() - } - try { - const resolved = await resolveArtifactPath(root, key, false) - if (resolved.destination !== destination) { - throw new Error("Artifact storage path changed during the write.") + try { + await file.writeFile(bytes) + await file.sync() + } finally { + await file.close() + } + try { + const resolved = await resolveArtifactPath(root, key, false) + if (resolved.destination !== destination) { + throw new Error("Artifact storage path changed during the write.") + } + await link(temporary, destination) + return { key, created: true } + } catch (error) { + if (!isFileSystemError(error, "EEXIST")) throw error + await verifyExisting(resolvedRoot, destination, contentDigest) + return { key, created: false } } - await link(temporary, destination) - return { key, created: true } - } catch (error) { - if (!isFileSystemError(error, "EEXIST")) throw error - await verifyExisting(resolvedRoot, destination, contentDigest) - return { key, created: false } } finally { await rm(temporary, { force: true }) } @@ -180,7 +182,7 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { const { destination, resolvedRoot } = await resolveArtifactPath(root, key, false) const file = await openArtifact(resolvedRoot, destination) try { - return file.readFile() + return await file.readFile() } finally { await file.close() } diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 8fbe4ce9..42f27f8f 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -25,6 +25,7 @@ import { getPrototype, getVersion, getWorkspace, + listPublishers, listPrototypes, listVersions, listWorkspaces, @@ -161,6 +162,7 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi productMutationLimit, createPublisherCredential.bind(null, authentication), ) + app.get("/api/prototypes/:prototypeId/publishers", listPublishers.bind(null, authentication)) app.patch( "/api/prototypes/:prototypeId/publishers/:publisherId", productMutationLimit, @@ -189,24 +191,16 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi app.get("/api/prototypes/:prototypeId/versions/:versionId", getVersion.bind(null, authentication)) app.use(["/api/workspaces", "/api/prototypes"], respondAdministrationError) if (authentication.artifactPublishing) { - const publishingLimit = createRateLimit({ - limit: 60, - windowMilliseconds: 5 * 60_000, - message: "Too many publishing requests.", - }) app.post( "/api/publishing/prototypes/:prototypeId/versions", - publishingLimit, publishArtifact.bind(null, authentication.artifactPublishing), ) app.get( "/api/publishing/prototypes/:prototypeId/status", - publishingLimit, getPublicationStatus.bind(null, authentication.artifactPublishing), ) app.put( "/api/publishing/prototypes/:prototypeId/channels/:channel", - publishingLimit, promoteVersion.bind(null, authentication.artifactPublishing), ) app.use("/api/publishing", respondAdministrationError) diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts index 546668f3..31f92a63 100644 --- a/hub/server/routes/product/index.test.ts +++ b/hub/server/routes/product/index.test.ts @@ -40,6 +40,7 @@ function runtime( archivePrototype: vi.fn(), setPrototypeAuthorization: vi.fn(), createPublisherCredential: vi.fn(), + listPublishers: vi.fn(), setPublisherActive: vi.fn(), revokePublisherCredential: vi.fn(), explainPrototypeAccess: vi.fn(), @@ -236,6 +237,11 @@ describe("product routes", () => { call: "createPublisherCredential", body: { name: "Local publisher", actions: ["publish"] }, }, + { + method: "GET", + path: "/api/prototypes/prototype/publishers", + call: "listPublishers", + }, { method: "PATCH", path: "/api/prototypes/prototype/publishers/publisher", @@ -399,6 +405,28 @@ describe("product routes", () => { expect(JSON.stringify(records)).not.toMatch(/private-query-value|private-body-value/) }) + it("forwards publisher pagination and returns the bounded metadata page", async () => { + const page = { + items: [{ id: "publisher-id", credentials: [{ id: "credential-id" }] }], + nextCursor: "publisher-id", + } + const listPublishers = vi.fn().mockResolvedValue(page) + const authentication = runtime({ listPublishers }) + const baseUrl = await serve(authentication) + + const response = await fetch( + `${baseUrl}/api/prototypes/prototype/publishers?limit=1&cursor=cursor-id`, + { headers: { cookie: `__Host-replica-session=${sessionToken}` } }, + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(page) + expect(listPublishers).toHaveBeenCalledWith(sessionToken, "prototype", { + limit: "1", + cursor: "cursor-id", + }) + }) + it("allows 120 product mutations per boundary and rejects the next one", async () => { const authentication = runtime({ createWorkspace: vi.fn().mockResolvedValue({ id: "workspace" }), diff --git a/hub/server/routes/product/index.ts b/hub/server/routes/product/index.ts index 2126c361..f946f38a 100644 --- a/hub/server/routes/product/index.ts +++ b/hub/server/routes/product/index.ts @@ -165,6 +165,19 @@ export async function createPublisherCredential( response.status(201).json(result) } +export async function listPublishers( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const publishers = await authentication.prototypeAuthorization.listPublishers( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + request.query, + ) + response.json(publishers) +} + export async function setPublisherActive( authentication: AuthenticationRoutes, request: Request, diff --git a/hub/server/routes/publishing.test.ts b/hub/server/routes/publishing.test.ts index c22888f5..58fd5437 100644 --- a/hub/server/routes/publishing.test.ts +++ b/hub/server/routes/publishing.test.ts @@ -232,4 +232,74 @@ describe("publishing routes", () => { expect(records[0]).not.toHaveProperty("body") expect(JSON.stringify(records)).not.toContain(privateContent.slice(0, 100)) }) + + it("applies publishing rate limits before parsing malformed JSON", async () => { + const getStatus = vi.fn().mockResolvedValue({ publicationId: null }) + const publish = vi.fn() + const baseUrl = await serve( + runtime({ getStatus, publish }), + createLogger({ destination: { write: () => undefined } }), + ) + + for (let request = 0; request < 60; request++) { + const response = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/status`, { + headers: { authorization: "******" }, + }) + expect(response.status).toBe(200) + } + const rejected = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "******", + "content-type": "application/json", + "idempotency-key": "rate-limited", + }, + body: "{", + }) + + expect(rejected.status).toBe(429) + expect(await rejected.json()).toEqual({ + error: "rate_limited", + message: "Too many publishing requests.", + }) + expect(getStatus).toHaveBeenCalledTimes(60) + expect(publish).not.toHaveBeenCalled() + }) + + it("uses the larger JSON limit only for version uploads", async () => { + const publish = vi.fn().mockResolvedValue({ + version: { id: "version-id" }, + replayed: false, + promotedChannel: null, + }) + const promote = vi.fn() + const baseUrl = await serve(runtime({ publish, promote })) + const padding = "x".repeat(128 * 1024) + + const upload = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "******", + "content-type": "application/json", + "idempotency-key": "larger-parser", + }, + body: JSON.stringify({ padding }), + }) + const promotion = await fetch( + `${baseUrl}/api/publishing/prototypes/prototype-id/channels/current`, + { + method: "PUT", + headers: { + authorization: "******", + "content-type": "application/json", + }, + body: JSON.stringify({ padding }), + }, + ) + + expect(upload.status).toBe(201) + expect(publish).toHaveBeenCalledOnce() + expect(promotion.status).toBe(413) + expect(promote).not.toHaveBeenCalled() + }) }) From 1cfba71a80c4d30a00e68659850663b30171a505 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 14:11:41 -0700 Subject: [PATCH 32/56] test(hub): preserve baseline transaction coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 741b862f..bc2f07a5 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -2403,7 +2403,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { it("passes the mutation transaction to a synchronized prototype baseline", async () => { const baselineAccess = vi.fn().mockResolvedValue({ - role: "editor", + role: "administrator", repositorySourceId: "repository-source", githubRepositoryId: "100", rawPermission: "write", @@ -2427,10 +2427,11 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) await expect( - service.createVersion("transaction-baseline-token", prototype.id, { - label: "Baseline version", + service.createPublisherCredential("transaction-baseline-token", prototype.id, { + name: "Baseline publisher", + actions: ["publish"], }), - ).resolves.toMatchObject({ prototypeId: prototype.id, sequence: 1 }) + ).resolves.toMatchObject({ publisher: { prototypeId: prototype.id } }) expect(baselineAccess).toHaveBeenCalledOnce() expect(baselineAccess.mock.calls[0]?.slice(1)).toEqual([actor.user.id, prototype.id]) From 265bf23cebc58f2c447ab7ad342174c0ca2a64d8 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 14:16:23 -0700 Subject: [PATCH 33/56] fix(hub): integrate final authorization parent Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 7 ++++--- .../prototype-authorization/create-service/index.ts | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index bc2f07a5..db22b04d 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -2098,10 +2098,11 @@ describe("createPrototypeAuthorizationService() database behavior", () => { service.getVersion("baseline-single-prototype-token", prototype.id, firstVersion.id), ).resolves.toMatchObject({ id: firstVersion.id }) await expect( - service.createVersion("baseline-single-prototype-token", prototype.id, { - label: "Second", + service.createPublisherCredential("baseline-single-prototype-token", prototype.id, { + name: "Baseline publisher", + actions: ["publish"], }), - ).resolves.toMatchObject({ sequence: 2 }) + ).resolves.toMatchObject({ publisher: { prototypeId: prototype.id } }) await expect( service.setPrototypeAccess("baseline-single-prototype-token", prototype.id, target.user.id, { effect: "allow", diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 950b24bd..87d4084a 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -956,10 +956,10 @@ export function createPrototypeAuthorizationService({ requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") if (prototype.archivedAt) { @@ -1014,10 +1014,10 @@ export function createPrototypeAuthorizationService({ requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") const items = await transaction.workloadPublisher.findMany({ @@ -1067,10 +1067,10 @@ export function createPrototypeAuthorizationService({ ) await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") if (prototype.archivedAt && parsed.data.active) { @@ -1123,10 +1123,10 @@ export function createPrototypeAuthorizationService({ ) await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") const credential = await transaction.workloadCredential.findFirst({ From e36923e8423a0e34e646ecc2ff38bf207157c3c1 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 16:04:32 -0700 Subject: [PATCH 34/56] fix(hub): harden artifact durability and recovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/artifact-ingestion.md | 4 +- hub/docs/logging.md | 2 +- hub/docs/testing-and-operations.md | 2 +- hub/server/create-app/index.ts | 2 + .../create-service/index.db.test.ts | 130 +++++++++++++++++- .../create-runtime/index.test.ts | 37 +++-- .../authentication/create-runtime/index.ts | 6 +- .../create-service/index.db.test.ts | 129 ++++++++++++++++- .../create-service/index.ts | 60 ++++---- .../create-local-storage/index.test.ts | 76 ++++++++++ .../create-local-storage/index.ts | 18 +++ .../prepare-publishing-request/index.ts | 11 ++ hub/server/routes/publishing.test.ts | 8 ++ 13 files changed, 429 insertions(+), 56 deletions(-) create mode 100644 hub/server/integrations/express/prepare-publishing-request/index.ts diff --git a/hub/docs/artifact-ingestion.md b/hub/docs/artifact-ingestion.md index 03a663b1..97b3826c 100644 --- a/hub/docs/artifact-ingestion.md +++ b/hub/docs/artifact-ingestion.md @@ -57,7 +57,7 @@ Publishing requests are rate-limited before JSON parsing. The version-upload rou `ArtifactStorage` separates ingestion from byte storage. The reference adapter writes content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`, defaulting to `~/.replica/artifacts` outside the source checkout and Vite development root. Database rows store only the storage key and validated metadata, never the artifact bytes. -The local adapter is suitable for one Hub process with a durable mounted filesystem. It rejects symbolic-link roots, shards, and artifact paths, resolves directories under the configured root, and opens artifact files without following a final symbolic link. Operators must keep the storage directory writable only by the Hub workload; the local adapter does not provide replication, remote durability, encryption-key management, hostile concurrent filesystem isolation, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. +The local adapter is suitable for one Hub process with a durable mounted filesystem. It rejects relative configured paths and symbolic-link roots, shards, and artifact paths, resolves directories under the configured root, opens artifact files without following a final symbolic link, and syncs file and directory entries before reporting a successful write. Operators must keep the storage directory writable only by the Hub workload; the local adapter does not provide replication, remote durability, encryption-key management, hostile concurrent filesystem isolation, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. Storage happens before the database transaction. A failed transaction can therefore leave an unreferenced content-addressed file. Hub never deletes referenced bytes automatically. Operators may remove only files that have no matching `PrototypeVersion.storageKey`; retain unreferenced files long enough to exclude in-flight transactions and backups. Version metadata has no deletion API and must not be rewritten silently. @@ -73,7 +73,7 @@ Publishing locks the prototype while it assigns the next sequence. Concurrent re ## Status, audits, and failures -`GET /api/publishing/prototypes/:prototypeId/status` reads the publication associated with the caller's `Idempotency-Key`. It requires `read-status`. +`GET /api/publishing/prototypes/:prototypeId/status` reads the publication associated with the caller's `Idempotency-Key`. It requires `read-status`. Publishing responses are private and non-cacheable because the idempotency key is carried in a request header rather than the URL. Version creation, idempotency ownership, optional promotion, and the `prototype.version.publish` security audit commit in one transaction. Promotion writes `prototype.channel.promote` in its transaction. Audit failure rolls back database state. Responses expose validated version metadata and stable error codes, not credentials, storage paths, or artifact contents. Publishing routes omit headers and request payloads from structured request logs. diff --git a/hub/docs/logging.md b/hub/docs/logging.md index 78b39aea..0a8bd4ec 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,7 +30,7 @@ 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. -`createApp()` installs `omitRequestLogPayload` for authentication, session, administration, product, and publishing paths after request logging but before JSON parsing. Publishing rate limits also run before parsing, and only the artifact-upload route receives the larger bounded parser. This ordering covers parser failures as well as later origin checks and handlers: +`createApp()` installs `omitRequestLogPayload` for authentication, session, administration, product, and publishing paths after request logging but before JSON parsing. Publishing responses receive private, non-cacheable headers before rate limits and parsing; publishing rate limits also run before parsing, and only the artifact-upload route receives the larger bounded parser. This ordering covers parser failures as well as later origin checks and handlers: ```ts import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 9253a6a1..5cffc417 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -55,7 +55,7 @@ Start the PostgreSQL service as described in [Development](development.md) and s 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. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. -Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, workload credential revocation, publisher suspension, archive lifecycle, and explainable decisions. Artifact publishing cases cover manifest bounds, credential-free provenance URLs, digest and size verification, scoped workload actions, token non-storage, idempotent replay and conflicts, concurrent sequence assignment, immutable rows, atomic promotion and rollback, revocation and disable races, cross-prototype isolation, audit rollback, body limits, request-log omission, and the local non-GitHub producer contract. Local storage cases reject symbolic-link roots, shards, and artifact paths. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, workload credential revocation, publisher suspension, provisioning authority, lock-free metadata reads, archive lifecycle, and explainable decisions. Artifact publishing cases cover manifest bounds, credential-free provenance URLs, digest and size verification, scoped workload actions, token non-storage, idempotent replay and conflicts, status isolation, concurrent sequence assignment, immutable rows, atomic publish-and-promotion, promotion rollback and replay, revocation and disable races, cross-prototype isolation, audit rollback, body limits, request-log omission, and the local non-GitHub producer contract. Local storage cases reject relative and symbolic-link paths and cover durable directory synchronization. GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover transactional cache revalidation, invitation precedence during source outage, immutable-ID rename and 404 invalidation, token-404 installation removal, credential-independent webhook revocation, retryable source-generation changes, per-result confirmation ordering, active-identity filtering, bounded provider requests and apply batches, partial scans, dirty-event retry coalescing, first-enrollment and admission-failure auditing, repository and installation lifecycle, provider-response versus apply failures, scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, manual suspension and audited administration preservation, compatibility membership isolation, synchronized session freshness, and rollback. diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index f58b96b0..14ed4740 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -4,6 +4,7 @@ import type { Logger } from "pino" import { maximumPublishingJsonBytes } from "../domains/artifact-publishing/create-service/constants.ts" import { createRateLimit } from "../integrations/express/create-rate-limit/index.ts" import { handleError } from "../integrations/express/handle-error/index.ts" +import { preparePublishingRequest } from "../integrations/express/prepare-publishing-request/index.ts" import { respondNotFound } from "../integrations/express/respond-not-found/index.ts" import { createRequestLogger } from "../integrations/pino/create-request-logger/index.ts" import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" @@ -32,6 +33,7 @@ export function createApp({ omitRequestLogPayload, ) app.use("/api/github/webhooks", express.raw({ type: "application/json", limit: "1mb" })) + app.use("/api/publishing", preparePublishingRequest) app.use( "/api/publishing", createRateLimit({ diff --git a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts index 79bb7303..e84f4867 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts @@ -833,6 +833,127 @@ describe("createArtifactPublishingService() database behavior", () => { ).toBe(1) }) + it("commits a promoted publication, version, channel, and audit together", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const input = publicationInput(prototype.id, artifact("publish-and-promote"), { + promoteChannel: "preview", + }) + + const result = await context.service.publish( + credential.authorization, + prototype.id, + "publish-and-promote", + input, + ) + const version = publishedVersion(result) + + expect(result).toMatchObject({ replayed: false, promotedChannel: "preview" }) + expect( + await context.prisma.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + select: { id: true, workloadPublisherId: true }, + }), + ).toEqual([{ id: version.id, workloadPublisherId: credential.publisher.id }]) + expect( + await context.prisma.artifactPublication.findMany({ + where: { prototypeId: prototype.id }, + select: { prototypeVersionId: true, workloadPublisherId: true }, + }), + ).toEqual([ + { + prototypeVersionId: version.id, + workloadPublisherId: credential.publisher.id, + }, + ]) + expect( + await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + }), + ).toEqual([{ name: "preview", prototypeVersionId: version.id }]) + expect( + await context.prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + select: { prototypeVersionId: true, workloadPublisherId: true }, + }), + ).toEqual([ + { + prototypeVersionId: version.id, + workloadPublisherId: credential.publisher.id, + }, + ]) + }) + + it("does not move a later channel promotion when replaying an earlier publication", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const firstInput = publicationInput(prototype.id, artifact("earlier-publication"), { + promoteChannel: "current", + }) + const first = await context.service.publish( + credential.authorization, + prototype.id, + "earlier-publication", + firstInput, + ) + const second = await context.service.publish( + credential.authorization, + prototype.id, + "later-publication", + publicationInput(prototype.id, artifact("later-publication"), { + promoteChannel: "current", + }), + ) + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "earlier-publication", + firstInput, + ), + ).resolves.toMatchObject({ + replayed: true, + version: publishedVersion(first), + promotedChannel: "current", + }) + expect( + await context.prisma.prototypeChannel.findUniqueOrThrow({ + where: { prototypeId_name: { prototypeId: prototype.id, name: "current" } }, + select: { prototypeVersionId: true }, + }), + ).toEqual({ prototypeVersionId: publishedVersion(second).id }) + }) + + it("isolates status reads for publishers that reuse the same idempotency key", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const firstPublisher = await createCredential(context.prisma, prototype.id) + const secondPublisher = await createCredential(context.prisma, prototype.id) + const first = await context.service.publish( + firstPublisher.authorization, + prototype.id, + "shared-request-key", + publicationInput(prototype.id, artifact("first publisher version")), + ) + const second = await context.service.publish( + secondPublisher.authorization, + prototype.id, + "shared-request-key", + publicationInput(prototype.id, artifact("second publisher version")), + ) + + await expect( + context.service.getStatus(firstPublisher.authorization, prototype.id, "shared-request-key"), + ).resolves.toMatchObject({ version: publishedVersion(first) }) + await expect( + context.service.getStatus(secondPublisher.authorization, prototype.id, "shared-request-key"), + ).resolves.toMatchObject({ version: publishedVersion(second) }) + }) + it("rejects reuse of an idempotency key for a different publication", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) @@ -1048,7 +1169,7 @@ describe("createArtifactPublishingService() database behavior", () => { ).toBe(0) }) - it("rolls back a published version when its audit cannot be saved", async () => { + it("rolls back a promoted publication, version, channel, and audit together", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) const credential = await createCredential(context.prisma, prototype.id) @@ -1074,7 +1195,9 @@ describe("createArtifactPublishingService() database behavior", () => { credential.authorization, prototype.id, "audit-failure", - publicationInput(prototype.id, artifact("audit-failure")), + publicationInput(prototype.id, artifact("audit-failure"), { + promoteChannel: "current", + }), ), ).rejects.toThrow("audit unavailable") } finally { @@ -1090,6 +1213,9 @@ describe("createArtifactPublishingService() database behavior", () => { expect( await context.prisma.artifactPublication.count({ where: { prototypeId: prototype.id } }), ).toBe(0) + expect( + await context.prisma.prototypeChannel.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) expect( await context.prisma.securityAudit.count({ where: { prototypeId: prototype.id, event: "prototype.version.publish" }, diff --git a/hub/server/domains/authentication/create-runtime/index.test.ts b/hub/server/domains/authentication/create-runtime/index.test.ts index 1fdf2b99..6e508317 100644 --- a/hub/server/domains/authentication/create-runtime/index.test.ts +++ b/hub/server/domains/authentication/create-runtime/index.test.ts @@ -5,7 +5,9 @@ import { describe, expect, it, vi } from "vitest" const { createLocalArtifactStorage } = vi.hoisted(() => ({ createLocalArtifactStorage: vi.fn((root: string) => { - void root + if (!path.isAbsolute(root)) { + throw new Error("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") + } return { put: vi.fn(), read: vi.fn(), @@ -22,15 +24,28 @@ vi.mock("#server/integrations/prisma", () => ({ prisma: {} })) import { createAuthenticationRuntime } from "#server/domains/authentication/create-runtime" describe("createAuthenticationRuntime()", () => { - it("keeps default artifact storage outside the checkout", async () => { - await createAuthenticationRuntime({}) - - expect(createLocalArtifactStorage).toHaveBeenCalledWith( - path.join(os.homedir(), ".replica", "artifacts"), - ) - const storagePath = createLocalArtifactStorage.mock.calls[0]?.[0] - expect(storagePath).toBeTypeOf("string") - if (!storagePath) throw new Error("Expected an artifact storage path") - expect(path.relative(process.cwd(), storagePath)).toMatch(/^\.\./) + it.each([undefined, "", " \t "])( + "keeps unset artifact storage outside the checkout for %j", + async (configuredPath) => { + await createAuthenticationRuntime({ + ...(configuredPath === undefined ? {} : { HUB_ARTIFACT_STORAGE_PATH: configuredPath }), + }) + + expect(createLocalArtifactStorage).toHaveBeenCalledWith( + path.join(os.homedir(), ".replica", "artifacts"), + ) + const storagePath = createLocalArtifactStorage.mock.calls[0]?.[0] + expect(storagePath).toBeTypeOf("string") + if (!storagePath) throw new Error("Expected an artifact storage path") + expect(path.relative(process.cwd(), storagePath)).toMatch(/^\.\./) + }, + ) + + it("passes a configured relative storage path unchanged to the storage adapter", async () => { + await expect( + createAuthenticationRuntime({ HUB_ARTIFACT_STORAGE_PATH: "relative/artifacts" }), + ).rejects.toThrow("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") + + expect(createLocalArtifactStorage).toHaveBeenCalledWith("relative/artifacts") }) }) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 9dce0c19..ba846dba 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -93,9 +93,9 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv admissionPolicy: admissionPolicy.name, trustedProxyHops: readTrustedProxyHops(environment), } - const artifactStoragePath = path.resolve( - environment.HUB_ARTIFACT_STORAGE_PATH ?? path.join(os.homedir(), ".replica", "artifacts"), - ) + const configuredArtifactStoragePath = environment.HUB_ARTIFACT_STORAGE_PATH?.trim() + const artifactStoragePath = + configuredArtifactStoragePath || path.join(os.homedir(), ".replica", "artifacts") return { administration: createAdministrationService({ store }), artifactPublishing: createArtifactPublishingService({ diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index db22b04d..d65299b0 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -13,13 +13,14 @@ async function database(baseline?: PrototypeBaselinePolicy) { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") onTestFinished(() => prisma.$disconnect()) + const store = createIdentityStore(prisma) return { prisma, - store: createIdentityStore(prisma), + store, service: createPrototypeAuthorizationService({ baseline, database: prisma, - store: createIdentityStore(prisma), + store, }), } } @@ -109,6 +110,20 @@ function deferred() { return { promise, resolve: resolvePromise } } +async function completesWithin(promise: Promise, milliseconds: number) { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), milliseconds) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + async function holdWorkspaceLock(database: PrismaClient, workspaceId: string) { const released = deferred() const locked = deferred() @@ -982,6 +997,46 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(await prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } })).toBe(0) }) + it.each(["viewer", "editor"] as const)( + "rejects a fresh %s provisioning a publisher without writing publisher state or an audit", + async (role) => { + const { prisma, service, store } = await database() + const administratorToken = `publisher-${role}-administrator-token` + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: `publisher-${role}-administrator`, + token: administratorToken, + }) + const actor = await createActor(store, { + subject: `publisher-${role}`, + token: `publisher-${role}-token`, + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: prototype.id, + userId: actor.user.id, + effect: "allow", + role, + }, + }) + + await expect( + service.createPublisherCredential(`publisher-${role}-token`, prototype.id, { + name: `${role} publisher`, + actions: ["publish"], + }), + ).rejects.toMatchObject({ status: 403, code: "prototype_access_denied" }) + expect(await prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } })).toBe(0) + expect(await prisma.workloadCredential.count()).toBe(0) + expect( + await prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.publisher.create" }, + }), + ).toBe(0) + }, + ) + it("lists bounded publisher and credential metadata without reusable secrets", async () => { const { prisma, service, store } = await database() const token = "publisher-list-token" @@ -1029,6 +1084,76 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(serialized).not.toContain("tokenDigest") }) + it.each(["stale-administrator", "fresh-editor"] as const)( + "rejects publisher metadata listing for a %s", + async (access) => { + const { prisma, service, store } = await database() + const administratorToken = `publisher-list-${access}-administrator-token` + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: `publisher-list-${access}-administrator`, + token: administratorToken, + }) + const token = + access === "stale-administrator" ? administratorToken : `publisher-list-${access}-token` + if (access === "stale-administrator") { + await prisma.session.updateMany({ + where: { tokenDigest: digestSecret(token) }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + } else { + const actor = await createActor(store, { + subject: `publisher-list-${access}`, + token, + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: prototype.id, + userId: actor.user.id, + effect: "allow", + role: "editor", + }, + }) + } + + await expect(service.listPublishers(token, prototype.id, {})).rejects.toMatchObject({ + status: 403, + code: + access === "stale-administrator" + ? "fresh_authorization_required" + : "prototype_access_denied", + }) + }, + ) + + it("lists publisher metadata without the identity-store or prototype write locks", async () => { + const { prisma, service, store } = await database() + const token = "publisher-list-lock-free-token" + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-list-lock-free", + token, + }) + const provisioned = (await service.createPublisherCredential(token, prototype.id, { + name: "Lock-free publisher", + actions: ["publish"], + })) as { publisher: { id: string } } + const identityStoreLock = vi.spyOn(store, "lock") + const prototypeLock = await holdPrototypeLock(prisma, prototype.id) + const listing = service.listPublishers(token, prototype.id, {}) + + const completedWhileLocked = await completesWithin(listing, 1_000) + prototypeLock.release() + await prototypeLock.holder + const page = (await listing) as { items: { id: string }[] } + + expect(completedWhileLocked).toBe(true) + expect(page.items).toEqual([expect.objectContaining({ id: provisioned.publisher.id })]) + expect(identityStoreLock).not.toHaveBeenCalled() + }) + it("does not apply publisher or credential lifecycle operations across prototypes", async () => { const { prisma, service, store } = await database() const token = "publisher-scope-token" diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 87d4084a..ad89a1d9 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -1008,43 +1008,35 @@ export function createPrototypeAuthorizationService({ async listPublishers(sessionToken, prototypeId, query) { const { cursor, limit } = parsePage(query) - return store.lock(async (_records, transaction) => { - const actor = await currentSession(store, sessionToken, transaction) - requireFreshAuthorization(actor) - requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") - await lockPrototype(transaction, prototypeId) - const { decision } = await prototypeDecision( - baseline, - transaction, - actor.userId, - prototypeId, - ) - requireDecision(decision, "administrator") - const items = await transaction.workloadPublisher.findMany({ - where: { prototypeId }, - orderBy: { id: "asc" }, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - take: limit + 1, - select: { - id: true, - name: true, - actions: true, - active: true, - createdAt: true, - updatedAt: true, - credentials: { - orderBy: { createdAt: "asc" }, - select: { - id: true, - expiresAt: true, - revokedAt: true, - createdAt: true, - }, + const actor = await currentSession(store, sessionToken) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) + requireDecision(decision, "administrator") + const items = await database.workloadPublisher.findMany({ + where: { prototypeId }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + select: { + id: true, + name: true, + actions: true, + active: true, + createdAt: true, + updatedAt: true, + credentials: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + expiresAt: true, + revokedAt: true, + createdAt: true, }, }, - }) - return pageResult(items, limit) + }, }) + return pageResult(items, limit) }, async setPublisherActive(sessionToken, prototypeId, publisherId, input) { diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts index d0273e2a..43884abf 100644 --- a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts @@ -33,6 +33,16 @@ function bytesInShard(shard: string) { } } +function directoryChainLength(directory: string) { + let length = 1 + let current = directory + while (path.dirname(current) !== current) { + length += 1 + current = path.dirname(current) + } + return length +} + async function expectSymlinkRejected({ root, key, @@ -117,6 +127,72 @@ describe("createLocalArtifactStorage()", () => { expect(Buffer.from(await storage.read(results[0].key))).toEqual(bytes) }) + it("syncs the destination directory chain before new and deduplicated puts return", async () => { + const root = storageRoot() + const storage = createLocalArtifactStorage(root) + const bytes = Buffer.from("durable artifact") + const contentDigest = digest(bytes) + const location = artifactPath(root, contentDigest) + const expectedDirectorySyncs = directoryChainLength(location.shard) + const probePath = path.join(process.cwd(), `.artifact-sync-probe-${randomUUID()}`) + const probe = await open(probePath, "w") + onTestFinished(async () => { + await probe.close() + await rm(probePath, { force: true }) + }) + const fileHandlePrototype = Object.getPrototypeOf(probe) as typeof probe + let directorySyncs = 0 + vi.spyOn(fileHandlePrototype, "sync").mockImplementation(async function (this: typeof probe) { + if ((await this.stat()).isDirectory()) directorySyncs += 1 + }) + + await expect(storage.put(contentDigest, bytes)).resolves.toMatchObject({ created: true }) + expect(directorySyncs).toBe(expectedDirectorySyncs) + + await expect(storage.put(contentDigest, bytes)).resolves.toMatchObject({ created: false }) + expect(directorySyncs).toBe(expectedDirectorySyncs * 2) + }) + + it("syncs the destination directory chain after a concurrent EEXIST dedupe", async () => { + const root = storageRoot() + const storage = createLocalArtifactStorage(root) + const bytes = Buffer.from("concurrent durable artifact") + const contentDigest = digest(bytes) + const expectedDirectorySyncs = directoryChainLength(artifactPath(root, contentDigest).shard) + const probePath = path.join(process.cwd(), `.artifact-sync-probe-${randomUUID()}`) + const probe = await open(probePath, "w") + onTestFinished(async () => { + await probe.close() + await rm(probePath, { force: true }) + }) + const fileHandlePrototype = Object.getPrototypeOf(probe) as typeof probe + let regularFileSyncs = 0 + let directorySyncs = 0 + let releaseRegularFileSyncs!: () => void + const bothTemporaryFilesSynced = new Promise((resolve) => { + releaseRegularFileSyncs = resolve + }) + vi.spyOn(fileHandlePrototype, "sync").mockImplementation(async function (this: typeof probe) { + const status = await this.stat() + if (status.isDirectory()) { + directorySyncs += 1 + } else { + regularFileSyncs += 1 + if (regularFileSyncs === 2) releaseRegularFileSyncs() + await bothTemporaryFilesSynced + } + }) + + const results = await Promise.all([ + storage.put(contentDigest, bytes), + storage.put(contentDigest, bytes), + ]) + + expect(results.filter(({ created }) => created)).toHaveLength(1) + expect(results.filter(({ created }) => !created)).toHaveLength(1) + expect(directorySyncs).toBe(expectedDirectorySyncs * 2) + }) + it.each(["writeFile", "sync"] as const)( "removes its temporary file when %s fails", async (operation) => { diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.ts index d2827ca6..d4c1854e 100644 --- a/hub/server/integrations/artifact-storage/create-local-storage/index.ts +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.ts @@ -130,6 +130,21 @@ async function verifyExisting( } } +async function syncDirectoryChain(directory: string): Promise { + let current = directory + while (true) { + const handle = await open(current, constants.O_RDONLY | constants.O_DIRECTORY) + try { + await handle.sync() + } finally { + await handle.close() + } + const parent = path.dirname(current) + if (parent === current) return + current = parent + } +} + export function createLocalArtifactStorage(root: string): ArtifactStorage { if (!path.isAbsolute(root)) { throw new Error("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") @@ -143,6 +158,7 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { const { destination, resolvedRoot } = await resolveArtifactPath(root, key, true) try { await verifyExisting(resolvedRoot, destination, contentDigest) + await syncDirectoryChain(path.dirname(destination)) return { key, created: false } } catch (error) { if (!isFileSystemError(error, "ENOENT")) throw error @@ -167,10 +183,12 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { throw new Error("Artifact storage path changed during the write.") } await link(temporary, destination) + await syncDirectoryChain(path.dirname(destination)) return { key, created: true } } catch (error) { if (!isFileSystemError(error, "EEXIST")) throw error await verifyExisting(resolvedRoot, destination, contentDigest) + await syncDirectoryChain(path.dirname(destination)) return { key, created: false } } } finally { diff --git a/hub/server/integrations/express/prepare-publishing-request/index.ts b/hub/server/integrations/express/prepare-publishing-request/index.ts new file mode 100644 index 00000000..a9e46676 --- /dev/null +++ b/hub/server/integrations/express/prepare-publishing-request/index.ts @@ -0,0 +1,11 @@ +import type { NextFunction, Request, Response } from "express" + +export function preparePublishingRequest( + _request: Request, + response: Response, + next: NextFunction, +): void { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + next() +} diff --git a/hub/server/routes/publishing.test.ts b/hub/server/routes/publishing.test.ts index 58fd5437..eccca88d 100644 --- a/hub/server/routes/publishing.test.ts +++ b/hub/server/routes/publishing.test.ts @@ -55,6 +55,11 @@ async function serve(authentication: AuthenticationRoutes, logger = createLogger return `http://127.0.0.1:${port}` } +function expectPublishingPrivacyHeaders(response: Response) { + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") +} + describe("publishing routes", () => { it("passes workload authorization and idempotency to publication without browser state", async () => { const publish = vi.fn().mockResolvedValue({ @@ -82,6 +87,7 @@ describe("publishing routes", () => { }) expect(response.status).toBe(201) + expectPublishingPrivacyHeaders(response) expect(await response.json()).toEqual({ version: { id: "version-id" }, replayed: false, @@ -226,6 +232,7 @@ describe("publishing routes", () => { }) expect(response.status).toBe(413) + expectPublishingPrivacyHeaders(response) expect(await response.json()).toEqual({ error: "Invalid request" }) expect(publish).not.toHaveBeenCalled() await vi.waitFor(() => expect(records).toHaveLength(1)) @@ -258,6 +265,7 @@ describe("publishing routes", () => { }) expect(rejected.status).toBe(429) + expectPublishingPrivacyHeaders(rejected) expect(await rejected.json()).toEqual({ error: "rate_limited", message: "Too many publishing requests.", From 90ba909db161dca17b336aae5add92678d7a012b Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 05:17:20 -0700 Subject: [PATCH 35/56] test(hub): align artifact tests with final parent Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 7 +++++-- hub/server/routes/product/index.test.ts | 7 ------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index d65299b0..24574036 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -588,7 +588,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }, ) - it.each([{ action: "archive" }, { action: "create a version" }] as const)( + it.each([{ action: "archive" }, { action: "create a publisher" }] as const)( "rejects a commenter attempting to $action without changing product state or audits", async ({ action }) => { const { prisma, service, store } = await database() @@ -621,7 +621,10 @@ describe("createPrototypeAuthorizationService() database behavior", () => { const operation = action === "archive" ? service.archivePrototype(token, prototype.id) - : service.createVersion(token, prototype.id, { label: "Rejected version" }) + : service.createPublisherCredential(token, prototype.id, { + name: "Rejected publisher", + actions: ["publish"], + }) await expect(operation).rejects.toMatchObject({ status: 403, diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts index 31f92a63..1c1babd7 100644 --- a/hub/server/routes/product/index.test.ts +++ b/hub/server/routes/product/index.test.ts @@ -172,13 +172,6 @@ describe("product routes", () => { call: "listVersions", expectedArguments: [sessionToken, "prototype-456", { cursor: "version-cursor", limit: "33" }], }, - { - method: "POST", - path: "/api/prototypes/prototype-456/versions", - call: "createVersion", - body: { label: "Mapped version" }, - expectedArguments: [sessionToken, "prototype-456", { label: "Mapped version" }], - }, { method: "GET", path: "/api/prototypes/prototype-456/versions/version-012", From ef9d71095f99f2c43617aff8b63fc3fc21ab25df Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 07:04:44 -0700 Subject: [PATCH 36/56] fix(hub): close artifact credential and storage gaps Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/artifact-ingestion.md | 2 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 231 +++++++++++++++++- .../create-service/index.ts | 10 +- .../create-local-storage/index.test.ts | 98 +++++--- .../create-local-storage/index.ts | 20 +- 6 files changed, 309 insertions(+), 54 deletions(-) diff --git a/hub/docs/artifact-ingestion.md b/hub/docs/artifact-ingestion.md index 97b3826c..f781dea9 100644 --- a/hub/docs/artifact-ingestion.md +++ b/hub/docs/artifact-ingestion.md @@ -57,7 +57,7 @@ Publishing requests are rate-limited before JSON parsing. The version-upload rou `ArtifactStorage` separates ingestion from byte storage. The reference adapter writes content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`, defaulting to `~/.replica/artifacts` outside the source checkout and Vite development root. Database rows store only the storage key and validated metadata, never the artifact bytes. -The local adapter is suitable for one Hub process with a durable mounted filesystem. It rejects relative configured paths and symbolic-link roots, shards, and artifact paths, resolves directories under the configured root, opens artifact files without following a final symbolic link, and syncs file and directory entries before reporting a successful write. Operators must keep the storage directory writable only by the Hub workload; the local adapter does not provide replication, remote durability, encryption-key management, hostile concurrent filesystem isolation, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. +The local adapter is suitable for one Hub process with a durable mounted filesystem. It rejects relative configured paths and symbolic-link roots, shards, and artifact paths, normalizes root spellings before validation, resolves directories under the configured root, opens artifact files without following a final symbolic link, and syncs file and directory entries through the storage-root boundary before reporting a successful write. Operators must keep the storage directory writable only by the Hub workload; the local adapter does not provide replication, remote durability, encryption-key management, hostile concurrent filesystem isolation, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. Storage happens before the database transaction. A failed transaction can therefore leave an unreferenced content-addressed file. Hub never deletes referenced bytes automatically. Operators may remove only files that have no matching `PrototypeVersion.storageKey`; retain unreferenced files long enough to exclude in-flight transactions and backups. Version metadata has no deletion API and must not be rewritten silently. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 5cffc417..190250db 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -55,7 +55,7 @@ Start the PostgreSQL service as described in [Development](development.md) and s 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. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. -Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, workload credential revocation, publisher suspension, provisioning authority, lock-free metadata reads, archive lifecycle, and explainable decisions. Artifact publishing cases cover manifest bounds, credential-free provenance URLs, digest and size verification, scoped workload actions, token non-storage, idempotent replay and conflicts, status isolation, concurrent sequence assignment, immutable rows, atomic publish-and-promotion, promotion rollback and replay, revocation and disable races, cross-prototype isolation, audit rollback, body limits, request-log omission, and the local non-GitHub producer contract. Local storage cases reject relative and symbolic-link paths and cover durable directory synchronization. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, workload credential revocation, publisher suspension, provisioning authority, lock-free metadata reads, archive lifecycle, and explainable decisions. Artifact publishing cases cover manifest bounds, credential-free provenance URLs, digest and size verification, credential expiry and forgery, scoped workload actions, token non-storage, idempotent replay and conflicts, status isolation, concurrent sequence assignment, immutable rows, atomic publish-and-promotion, promotion rollback and replay, revocation and disable races, cross-prototype isolation, audit rollback, body limits, request-log omission, and the local non-GitHub producer contract. Local storage cases reject normalized relative and symbolic-link paths and cover durable directory synchronization without requiring access above the configured root. GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover transactional cache revalidation, invitation precedence during source outage, immutable-ID rename and 404 invalidation, token-404 installation removal, credential-independent webhook revocation, retryable source-generation changes, per-result confirmation ordering, active-identity filtering, bounded provider requests and apply batches, partial scans, dirty-event retry coalescing, first-enrollment and admission-failure auditing, repository and installation lifecycle, provider-response versus apply failures, scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, manual suspension and audited administration preservation, compatibility membership isolation, synchronized session freshness, and rollback. diff --git a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts index e84f4867..79f88f23 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts @@ -134,6 +134,20 @@ async function holdPublisherAuthorizationRow( return { holder, release: released.resolve } } +async function holdPrototypeRow(database: PrismaClient, prototypeId: string) { + const released = deferred() + const locked = deferred() + const holder = database.$transaction(async (transaction) => { + await transaction.$queryRaw` + SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE + ` + locked.resolve() + await released.promise + }) + await locked.promise + return { holder, release: released.resolve } +} + async function createPrototype(prisma: PrismaClient) { const user = await prisma.user.create({ data: { displayName: "Publisher owner" } }) const workspace = await prisma.workspace.create({ @@ -196,7 +210,7 @@ async function createAdministratorSession(prisma: PrismaClient, token: string) { return { actor, store } } -async function provisionPublisher(prisma: PrismaClient, sessionToken: string) { +async function createPublisherAdministrator(prisma: PrismaClient, sessionToken: string) { const { actor, store } = await createAdministratorSession(prisma, sessionToken) const workspace = await prisma.workspace.create({ data: { @@ -219,6 +233,11 @@ async function provisionPublisher(prisma: PrismaClient, sessionToken: string) { }, }) const authorization = createPrototypeAuthorizationService({ database: prisma, store }) + return { authorization, prototype } +} + +async function provisionPublisher(prisma: PrismaClient, sessionToken: string) { + const { authorization, prototype } = await createPublisherAdministrator(prisma, sessionToken) const provisioned = (await authorization.createPublisherCredential(sessionToken, prototype.id, { name: "CI publisher", actions: ["publish", "read-status"], @@ -437,6 +456,23 @@ describe("createArtifactPublishingService() database behavior", () => { ).resolves.toMatchObject({ replayed: false }) }) + it("reports a correctly shaped artifact over the decoded size limit as too large", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifactBytes(Buffer.alloc(maximumArtifactBytes + 1, 0x61)) + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "oversized-artifact", + publicationInput(prototype.id, publishedArtifact), + ), + ).rejects.toMatchObject({ status: 413, code: "artifact_too_large" }) + expect(await context.prisma.prototypeVersion.count()).toBe(0) + }) + it("rejects noncanonical base64 as an invalid artifact instead of an oversized artifact", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) @@ -554,6 +590,31 @@ describe("createArtifactPublishingService() database behavior", () => { expect(JSON.stringify(saved)).not.toContain(provisioned.credential.token) }) + it("rejects provisioning a workload credential that is already expired", async () => { + const context = await database() + const sessionToken = "expired-provisioning-session" + const { authorization, prototype } = await createPublisherAdministrator( + context.prisma, + sessionToken, + ) + + await expect( + authorization.createPublisherCredential(sessionToken, prototype.id, { + name: "Expired publisher", + actions: ["publish", "read-status"], + expiresAt: new Date(Date.now() - 1_000).toISOString(), + }), + ).rejects.toMatchObject({ status: 422, code: "invalid_workload_publisher" }) + expect( + await context.prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.publisher.create" }, + }), + ).toBe(0) + }) + it("accepts and persists an ordinary HTTPS provenance source URL", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) @@ -754,6 +815,92 @@ describe("createArtifactPublishingService() database behavior", () => { }, ) + it.each(["publish", "promote"] as const)( + "rejects an already-started %s when its credential expires while waiting for the prototype lock", + async (operation) => { + const context = await database() + const sessionToken = `expiring-${operation}-session` + const { prototype, provisioned, workloadAuthorization } = await provisionPublisher( + context.prisma, + sessionToken, + ) + const published = + operation === "promote" + ? await context.service.publish( + workloadAuthorization, + prototype.id, + "expiration-promotion-source", + publicationInput(prototype.id, artifact("expiration-promotion-source")), + ) + : null + const expiresAt = new Date(Date.now() + 60_000) + await context.prisma.workloadCredential.update({ + where: { id: provisioned.credential.id }, + data: { expiresAt }, + }) + const before = { + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + channels: await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { + prototypeId: prototype.id, + event: { in: ["prototype.version.publish", "prototype.channel.promote"] }, + }, + }), + } + const prototypeLock = await holdPrototypeRow(context.prisma, prototype.id) + onTestFinished(prototypeLock.release) + const workloadResult = + operation === "publish" + ? context.service.publish( + workloadAuthorization, + prototype.id, + "expired-while-blocked", + publicationInput(prototype.id, artifact("expired-while-blocked")), + ) + : context.service.promote(workloadAuthorization, prototype.id, "current", { + versionId: publishedVersion(published!).id, + }) + await waitForBlockedDatabaseQuery(context.prisma, "%prototypes%FOR UPDATE%") + + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(expiresAt.getTime() + 1) + prototypeLock.release() + await prototypeLock.holder + + await expect(workloadResult).rejects.toMatchObject({ + status: 403, + code: "publisher_access_denied", + }) + expect({ + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + channels: await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { + prototypeId: prototype.id, + event: { in: ["prototype.version.publish", "prototype.channel.promote"] }, + }, + }), + }).toEqual(before) + }, + ) + it("enforces publish and read-status action scopes independently", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) @@ -781,6 +928,88 @@ describe("createArtifactPublishingService() database behavior", () => { ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) }) + it("rejects status reads after the workload credential expires", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + await context.service.publish( + credential.authorization, + prototype.id, + "expired-status", + publicationInput(prototype.id, artifact("expired-status")), + ) + await context.prisma.workloadCredential.update({ + where: { id: credential.credentialId }, + data: { expiresAt: new Date(Date.now() - 1_000) }, + }) + + await expect( + context.service.getStatus(credential.authorization, prototype.id, "expired-status"), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + }) + + it("rejects a canonical wrong secret for an existing credential before storage or state changes", async () => { + const put = vi.fn((contentDigest: string) => + Promise.resolve({ + key: contentDigest.replace(":", "/"), + created: true, + }), + ) + const storage: ArtifactStorage = { + put, + read: vi.fn(), + remove: vi.fn(), + } + const context = await database(storage) + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + await context.service.publish( + credential.authorization, + prototype.id, + "wrong-secret-status", + publicationInput(prototype.id, artifact("wrong-secret-status")), + ) + put.mockClear() + const wrongToken = `${["rpl", "pub"].join("_")}_${credential.credentialId}.${"w".repeat(43)}` + const wrongAuthorization = `Bearer ${wrongToken}` + const before = { + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + }), + } + + await expect( + context.service.getStatus(wrongAuthorization, prototype.id, "wrong-secret-status"), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + await expect( + context.service.publish( + wrongAuthorization, + prototype.id, + "wrong-secret-publish", + publicationInput(prototype.id, artifact("wrong-secret-publish")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + + expect(put).not.toHaveBeenCalled() + expect({ + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + }), + }).toEqual(before) + }) + it("rejects a credential issued for a different prototype", async () => { const context = await database() const first = await createPrototype(context.prisma) diff --git a/hub/server/domains/artifact-publishing/create-service/index.ts b/hub/server/domains/artifact-publishing/create-service/index.ts index c0ae530c..af3e1cfa 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.ts @@ -38,7 +38,7 @@ const manifestSchema = z format: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), version: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/), digest: z.string().regex(/^sha256:[a-f0-9]{64}$/), - sizeBytes: z.number().int().min(0).max(maximumArtifactBytes), + sizeBytes: z.number().int().min(0), builtAt: z.iso.datetime({ offset: true }), }) .strict(), @@ -57,7 +57,7 @@ const manifestSchema = z const publishSchema = z .object({ manifest: manifestSchema, - artifactBase64: z.string().max(Math.ceil((maximumArtifactBytes * 4) / 3) + 4), + artifactBase64: z.string(), promoteChannel: z.enum(["current", "preview"]).optional(), }) .strict() @@ -244,6 +244,12 @@ export function createArtifactPublishingService({ if (!parsed.success || parsed.data.manifest.prototypeId !== prototypeId) { error(422, "invalid_artifact_manifest", "The artifact manifest is invalid.") } + if ( + parsed.data.manifest.artifact.sizeBytes > maximumArtifactBytes || + parsed.data.artifactBase64.length > Math.ceil((maximumArtifactBytes * 4) / 3) + 4 + ) { + error(413, "artifact_too_large", "The artifact exceeds the maximum size.") + } const keyDigest = digestSecret(parseIdempotencyKey(idempotencyKey)) const bytes = parseArtifactBase64(parsed.data.artifactBase64) if (bytes.length !== parsed.data.manifest.artifact.sizeBytes) { diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts index 43884abf..1b369a50 100644 --- a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto" -import { mkdir, open, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises" +import { mkdir, open, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises" import path from "node:path" import { describe, expect, it, onTestFinished, vi } from "vitest" @@ -33,14 +33,9 @@ function bytesInShard(shard: string) { } } -function directoryChainLength(directory: string) { - let length = 1 - let current = directory - while (path.dirname(current) !== current) { - length += 1 - current = path.dirname(current) - } - return length +async function directoryIdentity(directory: string) { + const status = await stat(directory) + return `${status.dev}:${status.ino}` } async function expectSymlinkRejected({ @@ -133,7 +128,6 @@ describe("createLocalArtifactStorage()", () => { const bytes = Buffer.from("durable artifact") const contentDigest = digest(bytes) const location = artifactPath(root, contentDigest) - const expectedDirectorySyncs = directoryChainLength(location.shard) const probePath = path.join(process.cwd(), `.artifact-sync-probe-${randomUUID()}`) const probe = await open(probePath, "w") onTestFinished(async () => { @@ -141,16 +135,25 @@ describe("createLocalArtifactStorage()", () => { await rm(probePath, { force: true }) }) const fileHandlePrototype = Object.getPrototypeOf(probe) as typeof probe - let directorySyncs = 0 + const syncedDirectories: string[] = [] vi.spyOn(fileHandlePrototype, "sync").mockImplementation(async function (this: typeof probe) { - if ((await this.stat()).isDirectory()) directorySyncs += 1 + const status = await this.stat() + if (status.isDirectory()) syncedDirectories.push(`${status.dev}:${status.ino}`) }) await expect(storage.put(contentDigest, bytes)).resolves.toMatchObject({ created: true }) - expect(directorySyncs).toBe(expectedDirectorySyncs) + const expectedDirectories = await Promise.all([ + directoryIdentity(location.shard), + directoryIdentity(location.algorithm), + directoryIdentity(root), + ]) + const outsideRoot = await directoryIdentity(path.dirname(root)) + expect(syncedDirectories).toEqual(expectedDirectories) + expect(syncedDirectories).not.toContain(outsideRoot) await expect(storage.put(contentDigest, bytes)).resolves.toMatchObject({ created: false }) - expect(directorySyncs).toBe(expectedDirectorySyncs * 2) + expect(syncedDirectories).toEqual([...expectedDirectories, ...expectedDirectories]) + expect(syncedDirectories).not.toContain(outsideRoot) }) it("syncs the destination directory chain after a concurrent EEXIST dedupe", async () => { @@ -158,7 +161,7 @@ describe("createLocalArtifactStorage()", () => { const storage = createLocalArtifactStorage(root) const bytes = Buffer.from("concurrent durable artifact") const contentDigest = digest(bytes) - const expectedDirectorySyncs = directoryChainLength(artifactPath(root, contentDigest).shard) + const location = artifactPath(root, contentDigest) const probePath = path.join(process.cwd(), `.artifact-sync-probe-${randomUUID()}`) const probe = await open(probePath, "w") onTestFinished(async () => { @@ -167,7 +170,7 @@ describe("createLocalArtifactStorage()", () => { }) const fileHandlePrototype = Object.getPrototypeOf(probe) as typeof probe let regularFileSyncs = 0 - let directorySyncs = 0 + const syncedDirectories: string[] = [] let releaseRegularFileSyncs!: () => void const bothTemporaryFilesSynced = new Promise((resolve) => { releaseRegularFileSyncs = resolve @@ -175,7 +178,7 @@ describe("createLocalArtifactStorage()", () => { vi.spyOn(fileHandlePrototype, "sync").mockImplementation(async function (this: typeof probe) { const status = await this.stat() if (status.isDirectory()) { - directorySyncs += 1 + syncedDirectories.push(`${status.dev}:${status.ino}`) } else { regularFileSyncs += 1 if (regularFileSyncs === 2) releaseRegularFileSyncs() @@ -190,7 +193,15 @@ describe("createLocalArtifactStorage()", () => { expect(results.filter(({ created }) => created)).toHaveLength(1) expect(results.filter(({ created }) => !created)).toHaveLength(1) - expect(directorySyncs).toBe(expectedDirectorySyncs * 2) + const expectedDirectories = await Promise.all([ + directoryIdentity(location.shard), + directoryIdentity(location.algorithm), + directoryIdentity(root), + ]) + expect(syncedDirectories.toSorted()).toEqual( + [...expectedDirectories, ...expectedDirectories].toSorted(), + ) + expect(syncedDirectories).not.toContain(await directoryIdentity(path.dirname(root))) }) it.each(["writeFile", "sync"] as const)( @@ -249,29 +260,36 @@ describe("createLocalArtifactStorage()", () => { readFile.mockRestore() }) - it("rejects a symbolic-link storage root without reading, replaying, or removing outside content", async () => { - const root = storageRoot() - const outside = storageRoot() - const bytes = Buffer.from("outside root sentinel") - const contentDigest = digest(bytes) - const location = artifactPath(outside, contentDigest) - await mkdir(location.shard, { recursive: true }) - await writeFile(location.artifact, bytes) - await symlink(outside, root, "dir") - const writeBytes = Buffer.from("outside write probe") + it.each([ + { name: "directly", suffix: "" }, + { name: "with a trailing separator", suffix: path.sep }, + { name: "with a trailing current-directory segment", suffix: `${path.sep}.` }, + ])( + "rejects a symbolic-link storage root $name without accessing outside content", + async ({ suffix }) => { + const root = storageRoot() + const outside = storageRoot() + const bytes = Buffer.from("outside root sentinel") + const contentDigest = digest(bytes) + const location = artifactPath(outside, contentDigest) + await mkdir(location.shard, { recursive: true }) + await writeFile(location.artifact, bytes) + await symlink(outside, root, "dir") + const writeBytes = Buffer.from("outside write probe") - await expectSymlinkRejected({ - root, - key: location.key, - contentDigest, - bytes, - sentinel: location.artifact, - writeProbe: { - bytes: writeBytes, - outsidePath: artifactPath(outside, digest(writeBytes)).artifact, - }, - }) - }) + await expectSymlinkRejected({ + root: `${root}${suffix}`, + key: location.key, + contentDigest, + bytes, + sentinel: location.artifact, + writeProbe: { + bytes: writeBytes, + outsidePath: artifactPath(outside, digest(writeBytes)).artifact, + }, + }) + }, + ) it.each(["sha256", "shard"] as const)( "rejects a symbolic-link %s directory without reading, replaying, or removing outside content", diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.ts index d4c1854e..d10933c3 100644 --- a/hub/server/integrations/artifact-storage/create-local-storage/index.ts +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.ts @@ -130,7 +130,7 @@ async function verifyExisting( } } -async function syncDirectoryChain(directory: string): Promise { +async function syncDirectoryChain(directory: string, root: string): Promise { let current = directory while (true) { const handle = await open(current, constants.O_RDONLY | constants.O_DIRECTORY) @@ -139,8 +139,9 @@ async function syncDirectoryChain(directory: string): Promise { } finally { await handle.close() } + if (current === root) return const parent = path.dirname(current) - if (parent === current) return + if (!isWithinRoot(root, parent)) return current = parent } } @@ -149,16 +150,17 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { if (!path.isAbsolute(root)) { throw new Error("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") } + const normalizedRoot = path.resolve(root) return { async put(contentDigest: string, bytes: Uint8Array): Promise { const key = artifactKey(contentDigest) if (calculateContentDigest(bytes) !== contentDigest) { throw new Error("Artifact bytes do not match the content digest.") } - const { destination, resolvedRoot } = await resolveArtifactPath(root, key, true) + const { destination, resolvedRoot } = await resolveArtifactPath(normalizedRoot, key, true) try { await verifyExisting(resolvedRoot, destination, contentDigest) - await syncDirectoryChain(path.dirname(destination)) + await syncDirectoryChain(path.dirname(destination), resolvedRoot) return { key, created: false } } catch (error) { if (!isFileSystemError(error, "ENOENT")) throw error @@ -178,17 +180,17 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { await file.close() } try { - const resolved = await resolveArtifactPath(root, key, false) + const resolved = await resolveArtifactPath(normalizedRoot, key, false) if (resolved.destination !== destination) { throw new Error("Artifact storage path changed during the write.") } await link(temporary, destination) - await syncDirectoryChain(path.dirname(destination)) + await syncDirectoryChain(path.dirname(destination), resolvedRoot) return { key, created: true } } catch (error) { if (!isFileSystemError(error, "EEXIST")) throw error await verifyExisting(resolvedRoot, destination, contentDigest) - await syncDirectoryChain(path.dirname(destination)) + await syncDirectoryChain(path.dirname(destination), resolvedRoot) return { key, created: false } } } finally { @@ -197,7 +199,7 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { }, async read(key: string): Promise { - const { destination, resolvedRoot } = await resolveArtifactPath(root, key, false) + const { destination, resolvedRoot } = await resolveArtifactPath(normalizedRoot, key, false) const file = await openArtifact(resolvedRoot, destination) try { return await file.readFile() @@ -208,7 +210,7 @@ export function createLocalArtifactStorage(root: string): ArtifactStorage { async remove(key: string): Promise { try { - const { destination, resolvedRoot } = await resolveArtifactPath(root, key, false) + const { destination, resolvedRoot } = await resolveArtifactPath(normalizedRoot, key, false) await validateArtifactPath(resolvedRoot, destination) await unlink(destination) } catch (error) { From 5fea92977a37ff9c2d272e6c6acaa2905a22ea57 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 08:18:06 -0700 Subject: [PATCH 37/56] fix(hub): normalize artifact publishing inputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/artifact-ingestion.md | 4 +- hub/script/publish-artifact.mjs | 4 +- hub/script/tests/publish-artifact.test.mjs | 70 ++++++++++++++ .../create-service/index.db.test.ts | 91 +++++++++++++++++++ .../create-service/index.ts | 76 ++++++++++------ 5 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 hub/script/tests/publish-artifact.test.mjs diff --git a/hub/docs/artifact-ingestion.md b/hub/docs/artifact-ingestion.md index f781dea9..09a58d6f 100644 --- a/hub/docs/artifact-ingestion.md +++ b/hub/docs/artifact-ingestion.md @@ -49,7 +49,7 @@ Archiving a prototype blocks new publishers, publication, publisher re-enablemen } ``` -The manifest and its nested objects are closed to unknown fields. Hub bounds every string, accepts only HTTP or HTTPS source URLs without embedded usernames, passwords, query strings, or fragments, limits decoded artifacts to 8 MiB in this reference layer, verifies canonical base64, checks the decoded size, and recalculates the SHA-256 digest before storing bytes. The manifest prototype ID must match the route. +The manifest and its nested objects are closed to unknown fields. Hub bounds every string, rejects NUL characters, accepts only HTTP or HTTPS source URLs without embedded usernames, passwords, query strings, or fragments, limits decoded artifacts to 8 MiB in this reference layer, verifies canonical base64, checks the decoded size, and recalculates the SHA-256 digest before storing bytes. Valid UUIDs are normalized before scope and idempotency checks, so hexadecimal casing does not change identity. The manifest prototype ID must match the route. Publishing requests are rate-limited before JSON parsing. The version-upload route alone accepts the bounded upload-sized JSON body; status and promotion retain the API's default JSON limit. @@ -87,4 +87,4 @@ REPLICA_PUBLISH_TOKEN=... npm run publish:artifact -- \ http://127.0.0.1:3001 PROTOTYPE_ID ./prototype.tar preview ``` -Optional `REPLICA_PROVENANCE_PROVIDER`, `REPLICA_SOURCE_URL`, `REPLICA_REVISION`, `REPLICA_REF`, `REPLICA_PIPELINE`, and `REPLICA_RUN_ID` values populate provenance without changing authorization. +The CLI prints its generated or configured idempotency key before sending the request. If the response is lost, retry with that value in `REPLICA_IDEMPOTENCY_KEY` to recover the committed result. Optional `REPLICA_PROVENANCE_PROVIDER`, `REPLICA_SOURCE_URL`, `REPLICA_REVISION`, `REPLICA_REF`, `REPLICA_PIPELINE`, and `REPLICA_RUN_ID` values populate provenance without changing authorization. diff --git a/hub/script/publish-artifact.mjs b/hub/script/publish-artifact.mjs index 05d66d35..85c64017 100644 --- a/hub/script/publish-artifact.mjs +++ b/hub/script/publish-artifact.mjs @@ -24,6 +24,8 @@ if ( const bytes = await readFile(artifactPath) const details = await stat(artifactPath) const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}` + const idempotencyKey = process.env.REPLICA_IDEMPOTENCY_KEY ?? randomUUID() + console.error(`Replica idempotency key: ${idempotencyKey}`) const response = await fetch( new URL(`/api/publishing/prototypes/${prototypeId}/versions`, hubUrl), { @@ -31,7 +33,7 @@ if ( headers: { authorization: `Bearer ${token}`, "content-type": "application/json", - "idempotency-key": process.env.REPLICA_IDEMPOTENCY_KEY ?? randomUUID(), + "idempotency-key": idempotencyKey, }, body: JSON.stringify({ manifest: { diff --git a/hub/script/tests/publish-artifact.test.mjs b/hub/script/tests/publish-artifact.test.mjs new file mode 100644 index 00000000..42221cb4 --- /dev/null +++ b/hub/script/tests/publish-artifact.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { createServer } from "node:http" +import { once } from "node:events" +import { fileURLToPath } from "node:url" +import { it } from "node:test" + +const script = fileURLToPath(new URL("../publish-artifact.mjs", import.meta.url)) +const artifact = fileURLToPath(new URL("../../package.json", import.meta.url)) +const prototypeId = "50d2e31f-8aa1-4b67-8fb4-67f045b89a0d" + +function runPublisher(hubUrl, idempotencyKey) { + const child = spawn(process.execPath, [script, hubUrl, prototypeId, artifact], { + env: { + ...process.env, + REPLICA_PUBLISH_TOKEN: "test-token", + ...(idempotencyKey ? { REPLICA_IDEMPOTENCY_KEY: idempotencyKey } : {}), + }, + }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8").on("data", (chunk) => { + stdout += chunk + }) + child.stderr.setEncoding("utf8").on("data", (chunk) => { + stderr += chunk + }) + return once(child, "close").then(([code, signal]) => ({ code, signal, stdout, stderr })) +} + +it("prints the idempotency key before a failed publish so it can be reused", async (t) => { + const receivedKeys = [] + let requestCount = 0 + const server = createServer((request, response) => { + receivedKeys.push(request.headers["idempotency-key"]) + request.resume() + request.on("end", () => { + requestCount += 1 + if (requestCount === 1) { + response.writeHead(503, { "content-type": "application/json" }) + response.end('{"error":"response lost"}') + } else { + response.writeHead(201, { "content-type": "application/json" }) + response.end('{"replayed":true}') + } + }) + }) + server.listen(0, "127.0.0.1") + await once(server, "listening") + t.after(() => server.close()) + const address = server.address() + assert.ok(address && typeof address === "object") + const hubUrl = `http://127.0.0.1:${address.port}` + + const failed = await runPublisher(hubUrl) + const generatedKey = failed.stderr.match( + /Replica idempotency key: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i, + )?.[1] + assert.equal(failed.code, 1) + assert.equal(failed.signal, null) + assert.ok(generatedKey) + assert.match(failed.stderr, /Publish failed \(503\)/) + + const retried = await runPublisher(hubUrl, generatedKey) + assert.equal(retried.code, 0) + assert.equal(retried.signal, null) + assert.equal(retried.stderr, `Replica idempotency key: ${generatedKey}\n`) + assert.equal(retried.stdout, '{"replayed":true}\n') + assert.deepEqual(receivedKeys, [generatedKey, generatedKey]) +}) diff --git a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts index 79f88f23..ca68cfc0 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts @@ -440,6 +440,41 @@ describe("createArtifactPublishingService() database behavior", () => { expect(await context.prisma.prototypeVersion.count()).toBe(0) }) + it.each(["provider", "sourceUrl", "revision", "ref", "pipeline", "runId"] as const)( + "rejects a NUL in provenance $field before storage or state changes", + async (field) => { + const put = vi.fn(() => Promise.resolve({ key: "unused", created: true })) + const storage: ArtifactStorage = { + put, + read: () => Promise.reject(new Error("Artifact not found")), + remove: () => Promise.resolve(), + } + const context = await database(storage) + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const input = publicationInput(prototype.id, artifact(`nul-${field}`)) + input.manifest.provenance = { + ...input.manifest.provenance, + [field]: field === "sourceUrl" ? "https://ci.example/runs/\0hidden" : "visible\0hidden", + } + + await expect( + context.service.publish(credential.authorization, prototype.id, `nul-${field}`, input), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_artifact_manifest", + }) + expect(put).not.toHaveBeenCalled() + await expect( + Promise.all([ + context.prisma.prototypeVersion.count(), + context.prisma.artifactPublication.count(), + context.prisma.securityAudit.count(), + ]), + ).resolves.toEqual([0, 0, 0]) + }, + ) + it("publishes a valid artifact at the decoded size limit", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) @@ -1062,6 +1097,62 @@ describe("createArtifactPublishingService() database behavior", () => { ).toBe(1) }) + it("normalizes uppercase prototype identifiers across publishing operations", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const other = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const uppercasePrototypeId = prototype.id.toUpperCase() + const uppercaseInput = publicationInput( + uppercasePrototypeId, + artifact("uppercase-prototype-id"), + ) + + const first = await context.service.publish( + credential.authorization, + uppercasePrototypeId, + "uppercase-request", + uppercaseInput, + ) + const replay = await context.service.publish( + credential.authorization, + prototype.id, + "uppercase-request", + publicationInput(prototype.id, artifact("uppercase-prototype-id")), + ) + const status = await context.service.getStatus( + credential.authorization, + uppercasePrototypeId, + "uppercase-request", + ) + await context.service.promote(credential.authorization, uppercasePrototypeId, "current", { + versionId: publishedVersion(first).id, + }) + + expect(first).toMatchObject({ replayed: false }) + expect(replay).toMatchObject({ replayed: true, version: publishedVersion(first) }) + expect(status).toMatchObject({ version: publishedVersion(first) }) + await expect( + context.prisma.prototypeChannel.findUniqueOrThrow({ + where: { prototypeId_name: { prototypeId: prototype.id, name: "current" } }, + select: { prototypeVersionId: true }, + }), + ).resolves.toEqual({ prototypeVersionId: publishedVersion(first).id }) + await expect( + context.service.publish( + credential.authorization, + other.prototype.id.toUpperCase(), + "uppercase-cross-prototype", + publicationInput(other.prototype.id.toUpperCase(), artifact("isolated-uppercase")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + expect( + await context.prisma.prototypeVersion.count({ + where: { prototypeId: other.prototype.id }, + }), + ).toBe(0) + }) + it("commits a promoted publication, version, channel, and audit together", async () => { const context = await database() const { prototype } = await createPrototype(context.prisma) diff --git a/hub/server/domains/artifact-publishing/create-service/index.ts b/hub/server/domains/artifact-publishing/create-service/index.ts index af3e1cfa..230b0870 100644 --- a/hub/server/domains/artifact-publishing/create-service/index.ts +++ b/hub/server/domains/artifact-publishing/create-service/index.ts @@ -13,8 +13,14 @@ import type { ArtifactPublishingService, PrototypeChannelName, PublisherAction } import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" -const boundedIdentifier = z.string().trim().min(1).max(255) +const boundedIdentifier = z + .string() + .trim() + .min(1) + .max(255) + .refine((value) => !value.includes("\0")) function isSafeProvenanceUrl(value: string): boolean { + if (value.includes("\0")) return false try { const url = new URL(value) return ( @@ -76,10 +82,12 @@ function error(status: number, code: string, message: string): never { throw new AuthenticationError(status, code, message) } -function parsePrototypeId(prototypeId: string): void { - if (!prototypeIdSchema.safeParse(prototypeId).success) { +function parsePrototypeId(prototypeId: string): string { + const parsed = prototypeIdSchema.safeParse(prototypeId) + if (!parsed.success) { error(404, "prototype_not_found", "The prototype was not found.") } + return parsed.data.toLowerCase() } function parseIdempotencyKey(value: string): string { @@ -167,6 +175,7 @@ export function createArtifactPublishingService({ type PublisherAuthentication = { credentialId: string publisherId: string + prototypeId: string tokenDigest: string } @@ -182,7 +191,7 @@ export function createArtifactPublishingService({ } async function authenticate(authorization: string, prototypeId: string, action: PublisherAction) { - parsePrototypeId(prototypeId) + const normalizedPrototypeId = parsePrototypeId(prototypeId) const match = authorizationSchema.exec(authorization) if (!match?.[1] || !match[2]) { error(401, "publisher_authentication_required", "Publisher authentication failed.") @@ -198,7 +207,7 @@ export function createArtifactPublishingService({ credential.revokedAt || (credential.expiresAt && credential.expiresAt <= new Date()) || !credential.workloadPublisher.active || - credential.workloadPublisher.prototypeId !== prototypeId || + credential.workloadPublisher.prototypeId !== normalizedPrototypeId || !credential.workloadPublisher.actions.includes(action) || !secretsMatch(credential.tokenDigest, tokenDigest) ) { @@ -207,6 +216,7 @@ export function createArtifactPublishingService({ return { credentialId: credential.id, publisherId: credential.workloadPublisher.id, + prototypeId: normalizedPrototypeId, tokenDigest, } } @@ -227,7 +237,7 @@ export function createArtifactPublishingService({ (credential.expiresAt && credential.expiresAt <= new Date()) || !credential.workloadPublisher.active || credential.workloadPublisher.id !== authentication.publisherId || - credential.workloadPublisher.prototypeId !== prototypeId || + credential.workloadPublisher.prototypeId !== authentication.prototypeId || !credential.workloadPublisher.actions.includes(action) || !secretsMatch(credential.tokenDigest, authentication.tokenDigest) ) { @@ -239,9 +249,13 @@ export function createArtifactPublishingService({ return { async publish(authorization, prototypeId, idempotencyKey, input) { const authentication = await authenticate(authorization, prototypeId, "publish") - await requireUnarchivedPrototype(prototypeId) + const normalizedPrototypeId = authentication.prototypeId + await requireUnarchivedPrototype(normalizedPrototypeId) const parsed = publishSchema.safeParse(input) - if (!parsed.success || parsed.data.manifest.prototypeId !== prototypeId) { + if ( + !parsed.success || + parsed.data.manifest.prototypeId.toLowerCase() !== normalizedPrototypeId + ) { error(422, "invalid_artifact_manifest", "The artifact manifest is invalid.") } if ( @@ -260,21 +274,23 @@ export function createArtifactPublishingService({ } const requestDigest = digestSecret( JSON.stringify({ - manifest: parsed.data.manifest, + manifest: { ...parsed.data.manifest, prototypeId: normalizedPrototypeId }, promoteChannel: parsed.data.promoteChannel ?? null, }), ) const stored = await storage.put(parsed.data.manifest.artifact.digest, bytes) return database.$transaction(async (transaction) => { - await lockPrototype(transaction, prototypeId) + await lockPrototype(transaction, normalizedPrototypeId) const publisher = await requireCurrentPublisherAuthorization( transaction, authentication, - prototypeId, + normalizedPrototypeId, "publish", ) - const prototype = await transaction.prototype.findUnique({ where: { id: prototypeId } }) + const prototype = await transaction.prototype.findUnique({ + where: { id: normalizedPrototypeId }, + }) if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") if (prototype.archivedAt) { error(409, "prototype_archived", "Archived prototypes cannot receive versions.") @@ -304,7 +320,7 @@ export function createArtifactPublishingService({ } const duplicate = await transaction.prototypeVersion.findFirst({ where: { - prototypeId, + prototypeId: normalizedPrototypeId, contentDigest: parsed.data.manifest.artifact.digest, }, }) @@ -312,13 +328,13 @@ export function createArtifactPublishingService({ error(409, "artifact_already_published", "This artifact was already published.") } const latest = await transaction.prototypeVersion.findFirst({ - where: { prototypeId }, + where: { prototypeId: normalizedPrototypeId }, orderBy: { sequence: "desc" }, }) const manifest = parsed.data.manifest const version = await transaction.prototypeVersion.create({ data: { - prototypeId, + prototypeId: normalizedPrototypeId, workloadPublisherId: publisher.id, sequence: (latest?.sequence ?? 0) + 1, artifactFormat: manifest.artifact.format, @@ -337,7 +353,7 @@ export function createArtifactPublishingService({ }) await transaction.artifactPublication.create({ data: { - prototypeId, + prototypeId: normalizedPrototypeId, workloadPublisherId: publisher.id, prototypeVersionId: version.id, idempotencyKeyDigest: keyDigest, @@ -347,10 +363,13 @@ export function createArtifactPublishingService({ if (parsed.data.promoteChannel) { await transaction.prototypeChannel.upsert({ where: { - prototypeId_name: { prototypeId, name: parsed.data.promoteChannel }, + prototypeId_name: { + prototypeId: normalizedPrototypeId, + name: parsed.data.promoteChannel, + }, }, create: { - prototypeId, + prototypeId: normalizedPrototypeId, prototypeVersionId: version.id, name: parsed.data.promoteChannel, }, @@ -363,7 +382,7 @@ export function createArtifactPublishingService({ sessionId: null, authenticationProviderRevisionId: null, workspaceId: prototype.workspaceId, - prototypeId, + prototypeId: normalizedPrototypeId, prototypeVersionId: version.id, workloadPublisherId: publisher.id, context: { @@ -392,7 +411,7 @@ export function createArtifactPublishingService({ }, include: { prototypeVersion: true }, }) - if (!publication || publication.prototypeId !== prototypeId) { + if (!publication || publication.prototypeId !== authentication.prototypeId) { error(404, "publication_not_found", "The publication was not found.") } return { @@ -403,37 +422,40 @@ export function createArtifactPublishingService({ async promote(authorization, prototypeId, channel, input) { const authentication = await authenticate(authorization, prototypeId, "publish") + const normalizedPrototypeId = authentication.prototypeId const parsedChannel = channelSchema.safeParse(channel) const parsed = promotionSchema.safeParse(input) if (!parsedChannel.success || !parsed.success) { error(422, "invalid_promotion", "The promotion is invalid.") } return database.$transaction(async (transaction) => { - await lockPrototype(transaction, prototypeId) + await lockPrototype(transaction, normalizedPrototypeId) const publisher = await requireCurrentPublisherAuthorization( transaction, authentication, - prototypeId, + normalizedPrototypeId, "publish", ) - const prototype = await transaction.prototype.findUnique({ where: { id: prototypeId } }) + const prototype = await transaction.prototype.findUnique({ + where: { id: normalizedPrototypeId }, + }) if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") if (prototype.archivedAt) { error(409, "prototype_archived", "Archived prototypes cannot be promoted.") } const version = await transaction.prototypeVersion.findFirst({ - where: { id: parsed.data.versionId, prototypeId }, + where: { id: parsed.data.versionId, prototypeId: normalizedPrototypeId }, }) if (!version) error(404, "version_not_found", "The version was not found.") const pointer = await transaction.prototypeChannel.upsert({ where: { prototypeId_name: { - prototypeId, + prototypeId: normalizedPrototypeId, name: parsedChannel.data satisfies PrototypeChannelName, }, }, create: { - prototypeId, + prototypeId: normalizedPrototypeId, prototypeVersionId: version.id, name: parsedChannel.data, }, @@ -445,7 +467,7 @@ export function createArtifactPublishingService({ sessionId: null, authenticationProviderRevisionId: null, workspaceId: prototype.workspaceId, - prototypeId, + prototypeId: normalizedPrototypeId, prototypeVersionId: version.id, workloadPublisherId: publisher.id, context: { From 1a3457df2b5b47f584f1af976b7d131e0219b8b0 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 16:30:46 -0700 Subject: [PATCH 38/56] feat(hub): add production OIDC authentication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 17 + hub/README.md | 4 +- .../application/authentication/index.tsx | 8 +- .../pages/sign-in-page/index.test.tsx | 10 +- .../javascript/pages/sign-in-page/index.tsx | 11 +- .../javascript/pages/sign-in-page/stories.tsx | 1 + .../javascript/pages/sign-in-page/types.ts | 1 + hub/docs/README.md | 1 + hub/docs/architecture.md | 6 +- hub/docs/development.md | 4 +- hub/docs/entra-oidc.md | 58 ++ hub/docs/identity.md | 13 +- hub/docs/membership-and-administration.md | 4 +- hub/package-lock.json | 10 + hub/package.json | 1 + hub/schema/external-identity.prisma | 1 + .../migration.sql | 1 + hub/script/setup-entra-oidc.ps1 | 479 +++++++++++++++ hub/script/tests/setup-entra-oidc.test.mjs | 144 +++++ .../complete-oidc-authorization/index.ts | 301 ++++++++++ .../authentication/create-runtime/index.ts | 22 +- .../authentication/create-service/index.ts | 12 +- .../create-service/oidc.test.ts | 557 ++++++++++++++++++ .../authentication/create-service/types.ts | 7 + .../index.test.ts | 128 ++++ .../ensure-oidc-provider-revision/index.ts | 51 ++ .../read-oidc-configuration/index.test.ts | 108 ++++ .../read-oidc-configuration/index.ts | 92 +++ .../start-oidc-authorization/index.ts | 114 ++++ .../domains/identity/create-records/index.ts | 5 + .../domains/identity/create-store/types.ts | 3 + .../oidc/create-client/index.test.ts | 331 +++++++++++ .../integrations/oidc/create-client/index.ts | 200 +++++++ .../integrations/oidc/create-client/types.ts | 33 ++ .../get-latest-revision/index.ts | 12 + .../consume-attempt/index.ts | 4 +- .../save-external-identity/index.ts | 1 + .../repositories/external-identities/types.ts | 2 + .../api/auth/oidc/callback/get/index.ts | 1 + hub/server/routes/api/auth/oidc/post/index.ts | 1 + hub/server/routes/api/session/get/index.ts | 4 + hub/server/routes/authentication.test.ts | 60 ++ hub/server/routes/index.ts | 8 + 43 files changed, 2803 insertions(+), 28 deletions(-) create mode 100644 hub/docs/entra-oidc.md create mode 100644 hub/schema/migrations/20261027221500_oidc_identity_attributes/migration.sql create mode 100644 hub/script/setup-entra-oidc.ps1 create mode 100644 hub/script/tests/setup-entra-oidc.test.mjs create mode 100644 hub/server/domains/authentication/complete-oidc-authorization/index.ts create mode 100644 hub/server/domains/authentication/create-service/oidc.test.ts create mode 100644 hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts create mode 100644 hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts create mode 100644 hub/server/domains/authentication/read-oidc-configuration/index.test.ts create mode 100644 hub/server/domains/authentication/read-oidc-configuration/index.ts create mode 100644 hub/server/domains/authentication/start-oidc-authorization/index.ts create mode 100644 hub/server/integrations/oidc/create-client/index.test.ts create mode 100644 hub/server/integrations/oidc/create-client/index.ts create mode 100644 hub/server/integrations/oidc/create-client/types.ts create mode 100644 hub/server/repositories/authentication-providers/get-latest-revision/index.ts create mode 100644 hub/server/routes/api/auth/oidc/callback/get/index.ts create mode 100644 hub/server/routes/api/auth/oidc/post/index.ts diff --git a/hub/.env.example b/hub/.env.example index 13cd76bb..066414a3 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -7,6 +7,23 @@ LOG_LEVEL=info # Public browser origin. Production requires HTTPS; local HTTP must use loopback. HUB_ORIGIN=http://127.0.0.1:5173 +# Select one user sign-in provider. Omit only to retain the existing optional GitHub setup. +# HUB_AUTH_PROVIDER=oidc + +# Standards-based OIDC. The first documented profile is single-tenant Microsoft Entra ID. +# The credential reference names another environment variable; Hub never stores the secret. +# HUB_OIDC_ENABLED=true +# HUB_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 +# HUB_OIDC_CLIENT_ID= +# HUB_OIDC_CALLBACK_URL=https://hub.example.com/api/auth/oidc/callback +# HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE=HUB_OIDC_CLIENT_SECRET +# HUB_OIDC_CLIENT_SECRET= +# HUB_OIDC_MEMBER_ROLE=Hub.User +# HUB_OIDC_ADMINISTRATOR_ROLE=Hub.Administrator +# HUB_OIDC_SCOPES=openid profile email +# HUB_OIDC_MEMBERSHIP_POLICY=jit-assignment +# HUB_OIDC_PROVIDER_REVISION=1 + # Keep 0 unless the API is reachable only through this exact number of trusted proxies. HUB_TRUSTED_PROXY_HOPS=0 diff --git a/hub/README.md b/hub/README.md index 9d807df3..13b111e8 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,9 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API exposes its health check, GitHub authentication, sessions, installation membership administration, prototype authorization, and opt-in GitHub-synchronized admission and prototype baselines. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. +The API exposes its health check, GitHub or production OIDC authentication, sessions, installation membership administration, prototype authorization, opt-in GitHub-synchronized admission and prototype baselines, and provider-neutral artifact publishing. The frontend provides configured-provider sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, optional GitHub-synchronized authorization with Hub overrides, invitations, membership roles and lifecycle, administrator recovery, session revocation, and prototype metadata are implemented. Artifact ingestion, previews, reviews, general background-job infrastructure, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. One configured GitHub or OIDC provider proves user identity. Hub-owned admission, optional GitHub-synchronized authorization with Hub overrides, invitations, membership roles and lifecycle, administrator recovery, session revocation, prototype metadata, and artifact ingestion are implemented. Previews, reviews, SCIM, live updates, general background-job infrastructure, and deployment are not implemented. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx index 48e9f5e6..b9745cbf 100644 --- a/hub/app/javascript/application/authentication/index.tsx +++ b/hub/app/javascript/application/authentication/index.tsx @@ -11,6 +11,8 @@ import type { interface SessionResponse { authenticated: boolean configured: boolean + provider: "github" | "oidc" | null + providerLabel: string | null csrfToken: string user?: SignInPageUser membership?: { @@ -56,7 +58,8 @@ export function AuthenticationApplication() { setLoading(true) setError(null) try { - const response = await fetch("/api/auth/github", { + if (!session.provider) throw new Error("Sign-in provider is not configured") + const response = await fetch(`/api/auth/${session.provider}`, { method: "POST", credentials: "same-origin", headers: { "X-CSRF-Token": session.csrfToken }, @@ -65,7 +68,7 @@ export function AuthenticationApplication() { const data = (await response.json()) as { authorizationUrl: string } window.location.assign(data.authorizationUrl) } catch { - setError("Hub could not start GitHub sign-in.") + setError("Hub could not start sign-in.") setLoading(false) } } @@ -177,6 +180,7 @@ export function AuthenticationApplication() { return ( { - it("starts GitHub sign-in without presenting repository access as authentication", () => { + it("starts sign-in with the configured provider label without presenting repository access as authentication", () => { const onSignIn = vi.fn() render( { />, ) - fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" })) + fireEvent.click(screen.getByRole("button", { name: "Sign in with Company SSO" })) expect(onSignIn).toHaveBeenCalledOnce() + expect(screen.getByText(/Sign in with Company SSO to create a Hub session/)).toBeVisible() expect(screen.getByText(/Repository access is authorized separately/)).toBeVisible() }) @@ -43,7 +45,7 @@ describe("SignInPage()", () => { expect(screen.getByRole("button", { name: "Sign out" })).toBeEnabled() }) - it("disables sign-in when GitHub user authorization is not configured", () => { + it("disables provider-neutral sign-in when authentication is not configured", () => { render( { />, ) - expect(screen.getByRole("button", { name: "Sign in with GitHub" })).toBeDisabled() + expect(screen.getByRole("button", { name: "Sign in with identity provider" })).toBeDisabled() expect(screen.getByText(/has not been configured/)).toBeVisible() }) }) diff --git a/hub/app/javascript/pages/sign-in-page/index.tsx b/hub/app/javascript/pages/sign-in-page/index.tsx index 53b8fd7c..47c31f3d 100644 --- a/hub/app/javascript/pages/sign-in-page/index.tsx +++ b/hub/app/javascript/pages/sign-in-page/index.tsx @@ -9,6 +9,7 @@ import "./styles.css" export function SignInPage({ configured, + providerLabel, loading, user, error, @@ -39,8 +40,8 @@ export function SignInPage({ ) : ( <> - Sign in with GitHub to create a Hub session. Repository access is authorized - separately. + Sign in with {providerLabel ?? "the configured identity provider"} to create a Hub + session. Repository access is authorized separately. - {!configured && ( - GitHub sign-in has not been configured for this Hub. - )} + {!configured && User sign-in has not been configured for this Hub.} {configured ? (
diff --git a/hub/app/javascript/pages/sign-in-page/stories.tsx b/hub/app/javascript/pages/sign-in-page/stories.tsx index a2fbc192..b3aaf38f 100644 --- a/hub/app/javascript/pages/sign-in-page/stories.tsx +++ b/hub/app/javascript/pages/sign-in-page/stories.tsx @@ -8,6 +8,7 @@ const meta = { parameters: { layout: "fullscreen" }, args: { configured: true, + providerLabel: "Company SSO", loading: false, user: null, error: null, diff --git a/hub/app/javascript/pages/sign-in-page/types.ts b/hub/app/javascript/pages/sign-in-page/types.ts index e32f4081..99594387 100644 --- a/hub/app/javascript/pages/sign-in-page/types.ts +++ b/hub/app/javascript/pages/sign-in-page/types.ts @@ -6,6 +6,7 @@ export interface SignInPageUser { export interface SignInPageProps { configured: boolean + providerLabel?: string | null loading: boolean user: SignInPageUser | null error: string | null diff --git a/hub/docs/README.md b/hub/docs/README.md index 9fb427b6..36da0159 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. - [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. - [GitHub integration](github-integration.md): linked repository sources, reconciliation, webhooks, staleness, and permission mapping. +- [Microsoft Entra OIDC](entra-oidc.md): one-time enterprise-app setup and production OIDC configuration. - [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. - [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, Hub overrides, synchronized baselines, and policy precedence. - [Artifact ingestion](artifact-ingestion.md): workload publishers, the versioned manifest, storage, immutable publishing, idempotency, and promotion. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 58296e24..9e3e0c3e 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,9 +6,9 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, Hub-native prototype authorization, opt-in GitHub-synchronized admission and prototype baselines, and provider-neutral artifact publishing. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub or production OIDC authentication, Hub sessions, installation membership administration, Hub-native prototype authorization, opt-in GitHub-synchronized admission and prototype baselines, and provider-neutral artifact publishing. The frontend displays configured-provider sign-in, administrator enrollment and recovery, and a minimal membership administration page 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 system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, synchronized GitHub baselines, and one policy evaluator. The [GitHub integration](github-integration.md) stores stable repository source records and reconciles current permissions with installation credentials. The [artifact ingestion system](artifact-ingestion.md) authenticates prototype-scoped workloads, validates a shared manifest, stores bytes behind an adapter, creates immutable versions, and moves promotion pointers. Deployment and general background-job infrastructure have not been built. This app does not host previews, browse source, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization and standards-based OIDC, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when one user sign-in provider is configured. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, synchronized GitHub baselines, and one policy evaluator. The [GitHub integration](github-integration.md) stores stable repository source records and reconciles current permissions with installation credentials. The [artifact ingestion system](artifact-ingestion.md) authenticates prototype-scoped workloads, validates a shared manifest, stores bytes behind an adapter, creates immutable versions, and moves promotion pointers. Deployment and general background-job infrastructure have not been built. This app does not host previews, browse source, or support reviews. ## Backend @@ -35,7 +35,7 @@ Keep the existing `app/javascript/` layout. Application code starts React and wi Imports follow the arrows above. Shared components, hooks, and utilities must not import pages, features, or application code. Keep browser and server code separate. Agree on the API before adding types shared between them. -The frontend displays GitHub sign-in and session state inside the theme provider. An active administrator sees the minimal membership and invitation page. Existing prototype pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. +The frontend displays the configured provider sign-in and session state inside the theme provider. An active administrator sees the minimal membership and invitation page. Existing prototype pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. ## Other projects diff --git a/hub/docs/development.md b/hub/docs/development.md index dfe38c65..ac6e5dd1 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -1,6 +1,6 @@ # Hub development -Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. GitHub sign-in is optional in local development. Production deployment is not configured. +Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. User sign-in is optional in local development. Production deployment is not configured. ## Devcontainer @@ -20,7 +20,7 @@ cp .env.example .env npm ci --engine-strict ``` -The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in; leave both unset to run Hub with sign-in disabled. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. +The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in, or select and configure the complete OIDC profile in [Microsoft Entra OIDC](entra-oidc.md). Leave both providers unconfigured to run Hub with sign-in disabled. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. ## Start the app diff --git a/hub/docs/entra-oidc.md b/hub/docs/entra-oidc.md new file mode 100644 index 00000000..6cc3d660 --- /dev/null +++ b/hub/docs/entra-oidc.md @@ -0,0 +1,58 @@ +# Microsoft Entra OIDC + +Hub supports one user sign-in provider per installation. The first production OIDC profile is a single-tenant Microsoft Entra ID enterprise application, but Hub uses OpenID Connect discovery, authorization code, PKCE, nonce, and signed ID-token validation rather than an Entra authentication SDK. + +## Separate setup from sign-in + +A tenant administrator performs the one-time application setup. Ordinary users then sign in through the configured enterprise application. Tenant consent, application ownership, client-credential storage, and group assignment remain operator responsibilities; Hub does not bypass those approvals. + +Run the read-only preflight without Microsoft Graph permissions: + +```powershell +./script/setup-entra-oidc.ps1 -Preflight -HubOrigin https://hub.example.com +``` + +Install the listed Microsoft Graph PowerShell modules, sign in with the required tenant permissions, and inspect the intended changes: + +```powershell +./script/setup-entra-oidc.ps1 -Plan ` + -HubOrigin https://hub.example.com ` + -TenantId ` + -UserGroupId ` + -AdministratorGroupId +``` + +Use `-Apply` with the same arguments to converge the application, service principal, assignment requirement, callback, `Hub.User` and `Hub.Administrator` app roles, and group assignments. Use `-ExistingApplicationId ` to converge an existing application without claiming removal ownership. `-Remove` deletes only resources tagged as created by this script and refuses unowned resources. + +The script prints nonsecret Hub configuration. It does not create or print a client credential. Create the credential through the tenant's approved process, place it in the secret store exposed by `HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE`, and protect it separately from the database. + +## Hub configuration + +Set the complete OIDC profile. Hub does not fall back to GitHub when OIDC is selected or unavailable. + +```dotenv +HUB_AUTH_PROVIDER=oidc +HUB_OIDC_ENABLED=true +HUB_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 +HUB_OIDC_CLIENT_ID= +HUB_OIDC_CALLBACK_URL=https://hub.example.com/api/auth/oidc/callback +HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE=HUB_OIDC_CLIENT_SECRET +HUB_OIDC_CLIENT_SECRET= +HUB_OIDC_MEMBER_ROLE=Hub.User +HUB_OIDC_ADMINISTRATOR_ROLE=Hub.Administrator +HUB_OIDC_SCOPES=openid profile email +HUB_OIDC_MEMBERSHIP_POLICY=jit-assignment +HUB_OIDC_PROVIDER_REVISION=1 +``` + +The callback must exactly match `HUB_ORIGIN` plus `/api/auth/oidc/callback`. The issuer and discovered authorization, token, and JWKS endpoints must use the same public HTTPS origin. Increase `HUB_OIDC_PROVIDER_REVISION` whenever any OIDC setting or referenced credential changes. Hub rejects revision rollback and same-revision configuration drift. + +`jit-assignment` creates a membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. `invitation-required` also requires a matching Hub invitation. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. An administrator claim may promote an active member. Hub refuses a claim-driven demotion that would remove the final active administrator. + +## Identity and lifecycle boundaries + +OIDC proves the immutable tuple `provider + issuer/tenant + subject`. Preferred username, email, name, and picture are mutable display attributes and are never invitation, admission, or authorization keys. Hub discards the authorization code, ID token, access token, and any refresh token after extracting and validating identity and role claims. + +JIT provisioning occurs only during a successful sign-in and creates or refreshes Hub membership under the configured admission policy. It is not SCIM: Hub does not synchronize users, groups, suspension, or removal in the background. Entra assignment and app roles control installation admission and administrator mapping; they do not grant future prototype, repository, workspace, artifact, or review permissions. + +Hub sign-out revokes the opaque Hub session. It does not assume or initiate Entra single sign-out. Provider logout can be added later as a separately configured deployment behavior. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index e17e20b3..0e92d5dc 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,6 +1,13 @@ # Hub identity -Hub uses GitHub user authorization to prove an external identity. Hub then applies its configured installation admission and membership rules before creating a provider-neutral browser session. Repository permissions grant installation membership only when an operator explicitly selects GitHub-synchronized admission and opts in at least one linked repository. +Hub uses one configured user sign-in provider to prove an external identity. Hub then applies its own installation admission and membership rules before creating a provider-neutral browser session. Provider assignment and repository permissions do not grant future prototype authorization. + +## Production OIDC + +Hub supports standards-based OIDC authorization code flow as a sibling of GitHub user authorization. The first deployment profile is single-tenant Microsoft Entra ID with `Hub.User` and `Hub.Administrator` app roles. See [Microsoft Entra OIDC](entra-oidc.md) for the setup script, exact configuration, assignment policy, and operational boundary. + +OIDC discovery must return the exact configured issuer. Hub binds state to the initiating browser, uses PKCE and nonce, consumes attempts once, exchanges the code with a bounded timeout, verifies the ID-token signature through cached rotating JWKS, and validates issuer, audience, authorized party, token lifetime, and nonce. The configured issuer is host configuration, never request input. Hub rejects non-public or cross-origin discovery endpoints. +Hub maps `oidc + issuer/tenant + subject` to `ExternalIdentity`. Login, email, name, and picture are mutable display attributes. They never admit a user or select an existing identity. The authorization response and provider tokens are discarded after identity and app-role extraction. ## GitHub user authentication @@ -20,7 +27,7 @@ Hub sessions use a random opaque cookie and store only its digest. Authenticatio Artifact publishing uses a separate workload identity boundary. Prototype administrators issue prototype-scoped credentials with `publish` and/or `read-status`; Hub returns the raw bearer token once and stores only its digest. Workload credentials never create browser sessions or inherit user, installation, repository, or source-provider access. See [Artifact ingestion](artifact-ingestion.md). -Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. +Sign-out revokes the current database session. Provider logout is optional and is not assumed. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses the configured provider and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. ## Persisted boundaries @@ -58,6 +65,6 @@ Database credentials use AES-256-GCM with contextual additional authenticated da ## Current scope -This layer adds GitHub sign-in, Hub-managed and opt-in GitHub-synchronized installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary may consume a synchronized repository baseline without treating installation membership as product authority. Artifact ingestion composes with identity only when a browser administrator provisions a workload credential; publication itself does not use a browser session. Identity does not add production OIDC, account linking, or SCIM. +This layer adds explicit GitHub or OIDC sign-in, Hub-managed and opt-in GitHub-synchronized installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary may consume a synchronized repository baseline without treating installation membership as product authority. Artifact ingestion composes with identity only when a browser administrator provisions a workload credential; publication itself does not use a browser session. Identity does not add a multi-provider chooser, cross-provider account linking, SCIM or Graph lifecycle synchronization, or background Graph access. Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index 0ffb91bd..1925f139 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -2,6 +2,8 @@ Hub owns installation admission after an authentication provider proves an identity. Repository access affects installation membership only through the explicit GitHub-synchronized policy; Hub remains the final evaluator. +For OIDC, a configured member or administrator app role is required before Hub evaluates membership. Under `jit-assignment`, a first successful sign-in may create membership from that assignment. Under `invitation-required`, the immutable provider, issuer, and subject must also have a usable Hub invitation. Suspended membership remains suspended. Removed membership requires a new invitation and is never silently reactivated from a provider claim. + ## Membership contract Each Hub user has at most one installation membership. @@ -64,4 +66,4 @@ Membership operations write a structured security record with a bounded action, ## Non-goals -This boundary does not grant direct prototype, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines product access and can consume a repository baseline below Hub overrides. Membership synchronization does not browse source, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. +This boundary does not grant direct prototype, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines product access and can consume a synchronized repository baseline below Hub overrides. Entra assignment controls Hub installation admission, not prototype authorization. Hub does not link accounts across providers, provision or deprovision users through SCIM, synchronize Graph groups after setup, or grant artifact publishing authority from a user-provider claim. diff --git a/hub/package-lock.json b/hub/package-lock.json index 6594c6db..3cc47e3d 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -15,6 +15,7 @@ "@prisma/client": "7.10.1", "dotenv": "18.0.8", "express": "5.3.0", + "jose": "6.2.12", "pino": "10.4.0", "pino-http": "11.0.0", "react": "19.3.0", @@ -6974,6 +6975,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-6.2.12.tgz", + "integrity": "sha1-ZWY+FG7dAQuY7Og/gXN9Oqlf0Nc=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/hub/package.json b/hub/package.json index fa383979..7d1d8a04 100644 --- a/hub/package.json +++ b/hub/package.json @@ -59,6 +59,7 @@ "@primer/react-brand": "0.78.0", "dotenv": "18.0.8", "express": "5.3.0", + "jose": "6.2.12", "pino": "10.4.0", "pino-http": "11.0.0", "react": "19.3.0", diff --git a/hub/schema/external-identity.prisma b/hub/schema/external-identity.prisma index 8c7b25df..c1ac6ffb 100644 --- a/hub/schema/external-identity.prisma +++ b/hub/schema/external-identity.prisma @@ -13,6 +13,7 @@ model ExternalIdentity { issuer String subject String username String? + email String? displayName String? @map("display_name") avatarUrl String? @map("avatar_url") diff --git a/hub/schema/migrations/20261027221500_oidc_identity_attributes/migration.sql b/hub/schema/migrations/20261027221500_oidc_identity_attributes/migration.sql new file mode 100644 index 00000000..bd255190 --- /dev/null +++ b/hub/schema/migrations/20261027221500_oidc_identity_attributes/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "external_identities" ADD COLUMN "email" TEXT; diff --git a/hub/script/setup-entra-oidc.ps1 b/hub/script/setup-entra-oidc.ps1 new file mode 100644 index 00000000..782546c1 --- /dev/null +++ b/hub/script/setup-entra-oidc.ps1 @@ -0,0 +1,479 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $HubOrigin, + + [switch] $Plan, + [switch] $Apply, + [switch] $Remove, + [switch] $Preflight, + + [ValidateNotNullOrEmpty()] + [string] $TenantId, + + [ValidateNotNullOrEmpty()] + [string] $ExistingApplicationId, + + [ValidateNotNullOrEmpty()] + [string] $DisplayName = "Replica Hub", + + [ValidateRange(1, 2147483647)] + [int] $ProviderRevision = 1, + + [ValidatePattern("^[A-Z][A-Z0-9_]{2,127}$")] + [string] $ClientCredentialReference = "HUB_OIDC_CLIENT_SECRET", + + [ValidateSet("jit-assignment", "invitation-required")] + [string] $MembershipPolicy = "jit-assignment", + + [string[]] $UserGroupId = @(), + [string[]] $AdministratorGroupId = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptOwnerTag = "replica-hub:setup-entra-oidc:v1" +$userRoleId = [Guid] "8ee74d7b-bf6c-5fd6-a70a-b3efad17b3d4" +$administratorRoleId = [Guid] "624f20bd-df7b-5ed4-abeb-6f008f99920f" +$callbackPath = "/api/auth/oidc/callback" + +function Get-RequestedAction { + $actions = @($Plan, $Apply, $Remove, $Preflight).Where({ $_ }) + if ($actions.Count -ne 1) { + throw "Choose exactly one of -Plan, -Apply, -Remove, or -Preflight." + } + + if ($Plan) { return "Plan" } + if ($Apply) { return "Apply" } + if ($Remove) { return "Remove" } + return "Preflight" +} + +function Get-NormalizedOrigin { + try { + $origin = [Uri] $HubOrigin + } + catch { + throw "-HubOrigin must be an absolute HTTPS origin." + } + + if (-not $origin.IsAbsoluteUri -or $origin.Scheme -ne "https") { + throw "-HubOrigin must be an absolute HTTPS origin." + } + + if ($origin.AbsolutePath -ne "/" -or $origin.Query -or $origin.Fragment -or $origin.UserInfo) { + throw "-HubOrigin must contain only the HTTPS scheme and authority." + } + + return $origin.GetLeftPart([UriPartial]::Authority) +} + +function Get-AppRoles { + return @( + @{ + Id = $userRoleId + AllowedMemberTypes = @("User") + Description = "Use Replica Hub" + DisplayName = "Hub User" + IsEnabled = $true + Origin = "Application" + Value = "Hub.User" + }, + @{ + Id = $administratorRoleId + AllowedMemberTypes = @("User") + Description = "Administer Replica Hub" + DisplayName = "Hub Administrator" + IsEnabled = $true + Origin = "Application" + Value = "Hub.Administrator" + } + ) +} + +function Get-HubConfiguration { + param( + [string] $ResolvedTenantId, + [string] $ApplicationId, + [string] $CallbackUrl, + [string] $Status, + [string[]] $Actions + ) + + [pscustomobject] @{ + Status = $Status + Actions = @($Actions) + HUB_OIDC_ISSUER = if ($ResolvedTenantId) { + "https://login.microsoftonline.com/$ResolvedTenantId/v2.0" + } + else { + $null + } + HUB_OIDC_CLIENT_ID = if ($ApplicationId) { $ApplicationId } else { $ExistingApplicationId } + HUB_OIDC_CALLBACK_URL = $CallbackUrl + HUB_AUTH_PROVIDER = "oidc" + HUB_OIDC_ENABLED = "true" + HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE = $ClientCredentialReference + HUB_OIDC_SCOPES = "openid profile email" + HUB_OIDC_MEMBER_ROLE = "Hub.User" + HUB_OIDC_ADMINISTRATOR_ROLE = "Hub.Administrator" + HUB_OIDC_MEMBERSHIP_POLICY = $MembershipPolicy + HUB_OIDC_PROVIDER_REVISION = $ProviderRevision + } +} + +function Import-GraphModules { + foreach ($module in @( + "Microsoft.Graph.Authentication", + "Microsoft.Graph.Applications", + "Microsoft.Graph.Groups" + )) { + if (-not (Get-Module -ListAvailable -Name $module)) { + throw "Install the Microsoft Graph PowerShell module '$module' before running this action." + } + Import-Module $module -ErrorAction Stop + } +} + +function Connect-Graph { + param([string[]] $Scopes) + + Import-GraphModules + $connect = @{ Scopes = $Scopes; NoWelcome = $true } + if ($TenantId) { + $connect.TenantId = $TenantId + } + Connect-MgGraph @connect | Out-Null + + $context = Get-MgContext + if (-not $context -or -not $context.TenantId) { + throw "Microsoft Graph did not return a tenant context." + } + if ($TenantId -and $context.TenantId -ne $TenantId) { + throw "Connected tenant '$($context.TenantId)' does not match -TenantId '$TenantId'." + } + return $context +} + +function Get-ApplicationByAppId { + param([string] $ApplicationId) + + $escaped = $ApplicationId.Replace("'", "''") + $applications = @(Get-MgApplication -Filter "appId eq '$escaped'" -All) + if ($applications.Count -gt 1) { + throw "Microsoft Graph returned more than one application for app ID '$ApplicationId'." + } + return $applications | Select-Object -First 1 +} + +function Get-OwnedApplication { + $escaped = $DisplayName.Replace("'", "''") + $applications = @(Get-MgApplication -Filter "displayName eq '$escaped'" -All) + $owned = @($applications.Where({ @($_.Tags) -contains $scriptOwnerTag })) + if ($owned.Count -gt 1) { + throw "More than one script-owned application is named '$DisplayName'. Use -ExistingApplicationId." + } + if ($owned.Count -eq 1) { + return $owned[0] + } + if ($applications.Count -gt 0) { + throw "An application named '$DisplayName' exists but is not owned by this script. Use -ExistingApplicationId to manage it without granting removal ownership." + } + return $null +} + +function Resolve-Application { + if ($ExistingApplicationId) { + $application = Get-ApplicationByAppId -ApplicationId $ExistingApplicationId + if (-not $application) { + throw "Application '$ExistingApplicationId' was not found in the connected tenant." + } + return $application + } + return Get-OwnedApplication +} + +function Assert-ManagedRoleIdsAvailable { + param($Application) + + foreach ($role in @($Application.AppRoles)) { + if (($role.Id -eq $userRoleId -and $role.Value -ne "Hub.User") -or + ($role.Id -eq $administratorRoleId -and $role.Value -ne "Hub.Administrator") -or + ($role.Value -eq "Hub.User" -and $role.Id -ne $userRoleId) -or + ($role.Value -eq "Hub.Administrator" -and $role.Id -ne $administratorRoleId)) { + throw "The application has a conflicting Replica Hub app-role ID or value." + } + } +} + +function Get-ConvergedRoles { + param($Application) + + Assert-ManagedRoleIdsAvailable -Application $Application + $otherRoles = @($Application.AppRoles).Where({ + $_.Value -notin @("Hub.User", "Hub.Administrator") + }) + return @($otherRoles) + @(Get-AppRoles) +} + +function Test-ApplicationDrift { + param( + $Application, + [object[]] $DesiredRoles, + [string[]] $DesiredRedirectUris + ) + + if ($Application.SignInAudience -ne "AzureADMyOrg") { return $true } + $actualRedirectUris = @($Application.Web.RedirectUris) | Sort-Object + $expectedRedirectUris = @($DesiredRedirectUris) | Sort-Object + if (Compare-Object $actualRedirectUris $expectedRedirectUris) { return $true } + + foreach ($desired in Get-AppRoles) { + $actual = @($Application.AppRoles).Where({ $_.Value -eq $desired.Value }) + if ($actual.Count -ne 1) { return $true } + $actualRole = $actual[0] + if ($actualRole.Id -ne $desired.Id -or + $actualRole.DisplayName -ne $desired.DisplayName -or + $actualRole.Description -ne $desired.Description -or + -not $actualRole.IsEnabled -or + @($actualRole.AllowedMemberTypes).Count -ne 1 -or + $actualRole.AllowedMemberTypes[0] -ne "User") { + return $true + } + } + + if (@($Application.AppRoles).Count -ne $DesiredRoles.Count) { return $true } + return $false +} + +function Get-ConvergedRedirectUris { + param( + $Application, + [string] $CallbackUrl + ) + + if (@($Application.Tags) -contains $scriptOwnerTag) { + return @($CallbackUrl) + } + $redirectUris = @($Application.Web.RedirectUris) + @($CallbackUrl) + return @($redirectUris | Sort-Object -Unique) +} + +function Get-ServicePrincipal { + param([string] $ApplicationId) + + $escaped = $ApplicationId.Replace("'", "''") + $principals = @(Get-MgServicePrincipal -Filter "appId eq '$escaped'" -All) + if ($principals.Count -gt 1) { + throw "Microsoft Graph returned more than one service principal for app ID '$ApplicationId'." + } + return $principals | Select-Object -First 1 +} + +function Assert-GroupsExist { + param([string[]] $GroupIds) + + foreach ($groupId in $GroupIds | Sort-Object -Unique) { + Get-MgGroup -GroupId $groupId -Property "id,displayName" | Out-Null + } +} + +function Get-MissingAssignments { + param( + $ServicePrincipal, + [string[]] $UserGroups, + [string[]] $AdministratorGroups + ) + + $desired = @() + foreach ($groupId in $UserGroups | Sort-Object -Unique) { + $desired += [pscustomobject] @{ GroupId = $groupId; RoleId = $userRoleId; Role = "Hub.User" } + } + foreach ($groupId in $AdministratorGroups | Sort-Object -Unique) { + $desired += [pscustomobject] @{ GroupId = $groupId; RoleId = $administratorRoleId; Role = "Hub.Administrator" } + } + + $missing = @() + foreach ($assignment in $desired) { + $existing = @(Get-MgGroupAppRoleAssignment -GroupId $assignment.GroupId -All).Where({ + $_.ResourceId -eq $ServicePrincipal.Id -and $_.AppRoleId -eq $assignment.RoleId + }) + if ($existing.Count -eq 0) { + $missing += $assignment + } + } + return $missing +} + +function Invoke-Preflight { + param([string] $CallbackUrl) + + $actions = @( + "Install Microsoft.Graph.Authentication, Microsoft.Graph.Applications, and Microsoft.Graph.Groups.", + "Plan requires Application.Read.All and Group.Read.All.", + "Apply and Remove require Application.ReadWrite.All, AppRoleAssignment.ReadWrite.All, and Group.Read.All.", + "Tenant administrator approval must be completed through the tenant's normal consent process.", + "Apply requires at least one -UserGroupId and one -AdministratorGroupId." + ) + return Get-HubConfiguration -ResolvedTenantId $TenantId -ApplicationId $ExistingApplicationId ` + -CallbackUrl $CallbackUrl -Status "Preflight" -Actions $actions +} + +function Invoke-Plan { + param([string] $CallbackUrl) + + $context = Connect-Graph -Scopes @("Application.Read.All", "Group.Read.All") + Assert-GroupsExist -GroupIds @($UserGroupId + $AdministratorGroupId) + $application = Resolve-Application + $actions = @() + + if (-not $application) { + $actions += "Create single-tenant application '$DisplayName'." + $actions += "Create its service principal and require assignment." + foreach ($groupId in $UserGroupId | Sort-Object -Unique) { + $actions += "Assign group '$groupId' to Hub.User." + } + foreach ($groupId in $AdministratorGroupId | Sort-Object -Unique) { + $actions += "Assign group '$groupId' to Hub.Administrator." + } + } + else { + $roles = Get-ConvergedRoles -Application $application + $redirectUris = Get-ConvergedRedirectUris -Application $application -CallbackUrl $CallbackUrl + if (Test-ApplicationDrift -Application $application ` + -DesiredRoles $roles -DesiredRedirectUris $redirectUris) { + $actions += "Converge the callback, audience, and Replica Hub app roles." + } + $servicePrincipal = Get-ServicePrincipal -ApplicationId $application.AppId + if (-not $servicePrincipal) { + $actions += "Create the service principal and require assignment." + } + elseif (-not $servicePrincipal.AppRoleAssignmentRequired) { + $actions += "Require assignment on the service principal." + } + $missing = if ($servicePrincipal) { + Get-MissingAssignments -ServicePrincipal $servicePrincipal ` + -UserGroups $UserGroupId -AdministratorGroups $AdministratorGroupId + } + else { @() } + foreach ($assignment in $missing) { + $actions += "Assign group '$($assignment.GroupId)' to $($assignment.Role)." + } + } + + if ($UserGroupId.Count -eq 0 -or $AdministratorGroupId.Count -eq 0) { + $actions += "Provide at least one user group and one administrator group before Apply." + } + if ($actions.Count -eq 0) { + $actions += "No changes are required." + } + + return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` + -ApplicationId $(if ($application) { $application.AppId } else { $null }) ` + -CallbackUrl $CallbackUrl -Status "Plan" -Actions $actions +} + +function Invoke-Apply { + param([string] $CallbackUrl) + + if ($UserGroupId.Count -eq 0 -or $AdministratorGroupId.Count -eq 0) { + throw "-Apply requires at least one -UserGroupId and one -AdministratorGroupId." + } + + $context = Connect-Graph -Scopes @( + "Application.ReadWrite.All", + "AppRoleAssignment.ReadWrite.All", + "Group.Read.All" + ) + Assert-GroupsExist -GroupIds @($UserGroupId + $AdministratorGroupId) + $application = Resolve-Application + + if (-not $application) { + $application = New-MgApplication -DisplayName $DisplayName ` + -SignInAudience "AzureADMyOrg" ` + -Web @{ RedirectUris = @($CallbackUrl) } ` + -AppRoles (Get-AppRoles) ` + -Tags @($scriptOwnerTag) + } + else { + $roles = Get-ConvergedRoles -Application $application + $redirectUris = Get-ConvergedRedirectUris -Application $application -CallbackUrl $CallbackUrl + if (Test-ApplicationDrift -Application $application ` + -DesiredRoles $roles -DesiredRedirectUris $redirectUris) { + Update-MgApplication -ApplicationId $application.Id ` + -SignInAudience "AzureADMyOrg" ` + -Web @{ RedirectUris = $redirectUris } ` + -AppRoles $roles | Out-Null + $application = Get-ApplicationByAppId -ApplicationId $application.AppId + } + } + + $servicePrincipal = Get-ServicePrincipal -ApplicationId $application.AppId + if (-not $servicePrincipal) { + $servicePrincipal = New-MgServicePrincipal -AppId $application.AppId -Tags @($scriptOwnerTag) + } + if (-not $servicePrincipal.AppRoleAssignmentRequired) { + Update-MgServicePrincipal -ServicePrincipalId $servicePrincipal.Id ` + -AppRoleAssignmentRequired:$true | Out-Null + $servicePrincipal.AppRoleAssignmentRequired = $true + } + + $missing = Get-MissingAssignments -ServicePrincipal $servicePrincipal ` + -UserGroups $UserGroupId -AdministratorGroups $AdministratorGroupId + foreach ($assignment in $missing) { + New-MgGroupAppRoleAssignment -GroupId $assignment.GroupId ` + -PrincipalId $assignment.GroupId ` + -ResourceId $servicePrincipal.Id ` + -AppRoleId $assignment.RoleId | Out-Null + } + + return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` + -ApplicationId $application.AppId -CallbackUrl $CallbackUrl ` + -Status "Applied" -Actions @("Configuration converged.") +} + +function Invoke-Remove { + param([string] $CallbackUrl) + + $context = Connect-Graph -Scopes @( + "Application.ReadWrite.All", + "AppRoleAssignment.ReadWrite.All", + "Group.Read.All" + ) + $application = Resolve-Application + if (-not $application) { + return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` + -ApplicationId $null -CallbackUrl $CallbackUrl ` + -Status "Absent" -Actions @("No script-owned application exists.") + } + + if (@($application.Tags) -notcontains $scriptOwnerTag) { + throw "Refusing to remove application '$($application.AppId)' because it is not owned by this script." + } + + $servicePrincipal = Get-ServicePrincipal -ApplicationId $application.AppId + if ($servicePrincipal) { + if (@($servicePrincipal.Tags) -notcontains $scriptOwnerTag) { + throw "Refusing to remove service principal '$($servicePrincipal.Id)' because it is not owned by this script." + } + Remove-MgServicePrincipal -ServicePrincipalId $servicePrincipal.Id + } + Remove-MgApplication -ApplicationId $application.Id + + return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` + -ApplicationId $application.AppId -CallbackUrl $CallbackUrl ` + -Status "Removed" -Actions @("Removed script-owned application and service principal.") +} + +$action = Get-RequestedAction +$normalizedOrigin = Get-NormalizedOrigin +$callbackUrl = "$normalizedOrigin$callbackPath" + +switch ($action) { + "Preflight" { Invoke-Preflight -CallbackUrl $callbackUrl } + "Plan" { Invoke-Plan -CallbackUrl $callbackUrl } + "Apply" { Invoke-Apply -CallbackUrl $callbackUrl } + "Remove" { Invoke-Remove -CallbackUrl $callbackUrl } +} diff --git a/hub/script/tests/setup-entra-oidc.test.mjs b/hub/script/tests/setup-entra-oidc.test.mjs new file mode 100644 index 00000000..df75f818 --- /dev/null +++ b/hub/script/tests/setup-entra-oidc.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { describe, it } from "node:test" + +const script = await readFile(new URL("../setup-entra-oidc.ps1", import.meta.url), "utf8") + +function functionBody(name) { + const start = script.indexOf(`function ${name} {`) + assert.notEqual(start, -1, `Expected ${name} to exist`) + + const openingBrace = script.indexOf("{", start) + let depth = 0 + for (let index = openingBrace; index < script.length; index += 1) { + if (script[index] === "{") depth += 1 + if (script[index] === "}") depth -= 1 + if (depth === 0) return script.slice(openingBrace + 1, index) + } + assert.fail(`Expected ${name} to have a balanced body`) +} + +const mutatingCommands = + /\b(?:New|Update|Remove)-Mg(?:Application|ServicePrincipal|GroupAppRoleAssignment)\b/ + +describe("setup-entra-oidc.ps1", () => { + it("requires exactly one requested action", () => { + const action = functionBody("Get-RequestedAction") + + assert.match(action, /@\(\$Plan, \$Apply, \$Remove, \$Preflight\)\.Where/) + assert.match(action, /if \(\$actions\.Count -ne 1\)/) + assert.match(action, /Choose exactly one of -Plan, -Apply, -Remove, or -Preflight\./) + }) + + it("keeps preflight and plan read-only", () => { + const preflight = functionBody("Invoke-Preflight") + const plan = functionBody("Invoke-Plan") + + assert.doesNotMatch(preflight, /\bConnect-MgGraph\b|\b\w+-Mg\w+\b/) + assert.doesNotMatch(plan, mutatingCommands) + assert.doesNotMatch(plan, /\b(?:New|Update|Remove)-Mg\w+\b/) + assert.match(plan, /Application\.Read\.All/) + assert.match(plan, /Group\.Read\.All/) + }) + + it("converges only drift and missing assignments", () => { + const apply = functionBody("Invoke-Apply") + const drift = functionBody("Test-ApplicationDrift") + const assignments = functionBody("Get-MissingAssignments") + + assert.match(apply, /if \(-not \$application\)[\s\S]*New-MgApplication/) + assert.match(apply, /if \(Test-ApplicationDrift[\s\S]*Update-MgApplication/) + assert.match(apply, /if \(-not \$servicePrincipal\)[\s\S]*New-MgServicePrincipal/) + assert.match( + apply, + /if \(-not \$servicePrincipal\.AppRoleAssignmentRequired\)[\s\S]*Update-MgServicePrincipal/, + ) + assert.match( + assignments, + /\$_.ResourceId -eq \$ServicePrincipal\.Id -and \$_.AppRoleId -eq \$assignment\.RoleId/, + ) + assert.match(apply, /foreach \(\$assignment in \$missing\)[\s\S]*New-MgGroupAppRoleAssignment/) + assert.match(drift, /AzureADMyOrg/) + assert.match(drift, /Compare-Object \$actualRedirectUris \$expectedRedirectUris/) + assert.match(apply, /Assert-GroupsExist[\s\S]*Resolve-Application/) + assert.match(apply, /Get-MissingAssignments[\s\S]*foreach \(\$assignment in \$missing\)/) + }) + + it("fails closed before removing unowned resources", () => { + const remove = functionBody("Invoke-Remove") + const ownershipGuard = remove.indexOf("if (@($application.Tags) -notcontains $scriptOwnerTag)") + const principalGuard = remove.indexOf( + "if (@($servicePrincipal.Tags) -notcontains $scriptOwnerTag)", + ) + const removePrincipal = remove.indexOf("Remove-MgServicePrincipal") + const removeApplication = remove.indexOf("Remove-MgApplication") + + assert.ok(ownershipGuard >= 0) + assert.ok(principalGuard > ownershipGuard) + assert.ok(removePrincipal > principalGuard) + assert.ok(removeApplication > removePrincipal) + assert.match(remove, /Refusing to remove application/) + assert.match(remove, /Refusing to remove service principal/) + }) + + it("makes removal idempotent when no script-owned application exists", () => { + const remove = functionBody("Invoke-Remove") + const absentGuard = remove.indexOf("if (-not $application)") + const absentResult = remove.indexOf('-Status "Absent"', absentGuard) + const firstMutation = Math.min( + ...["Remove-MgServicePrincipal", "Remove-MgApplication"] + .map((command) => remove.indexOf(command)) + .filter((index) => index >= 0), + ) + + assert.ok(absentGuard >= 0) + assert.ok(absentResult > absentGuard) + assert.ok(firstMutation > absentResult) + assert.match(remove, /No script-owned application exists\./) + }) + + it("uses the exact callback, roles, and nonsecret output contract", () => { + const output = functionBody("Get-HubConfiguration") + + assert.match(script, /\$callbackPath = "\/api\/auth\/oidc\/callback"/) + assert.match(script, /Value = "Hub\.User"/) + assert.match(script, /Value = "Hub\.Administrator"/) + assert.match(script, /-AppRoleAssignmentRequired:\$true/) + assert.match(output, /HUB_OIDC_ISSUER/) + assert.match(output, /HUB_OIDC_CLIENT_ID/) + assert.match(output, /HUB_OIDC_CALLBACK_URL/) + assert.match(output, /HUB_AUTH_PROVIDER/) + assert.match(output, /HUB_OIDC_ENABLED/) + assert.match(output, /HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE/) + assert.match(output, /HUB_OIDC_SCOPES/) + assert.match(output, /HUB_OIDC_MEMBER_ROLE/) + assert.match(output, /HUB_OIDC_MEMBERSHIP_POLICY/) + assert.match(output, /HUB_OIDC_PROVIDER_REVISION/) + assert.doesNotMatch(output, /HUB_OIDC_CLIENT_SECRET|ClientSecret|CredentialValue/) + assert.doesNotMatch(script, /\bWrite-(?:Host|Output|Information|Verbose|Debug)\b/) + assert.doesNotMatch( + script, + /\b(?:Add-MgApplicationPassword|New-MgApplicationPassword|client_?secret|password credential)\b/i, + ) + }) + + it("does not claim removal ownership for an existing application", () => { + const resolve = functionBody("Resolve-Application") + const apply = functionBody("Invoke-Apply") + const redirects = functionBody("Get-ConvergedRedirectUris") + + assert.match(resolve, /if \(\$ExistingApplicationId\)[\s\S]*Get-ApplicationByAppId/) + const applicationUpdate = apply.match(/Update-MgApplication[\s\S]*?\| Out-Null/)?.[0] + assert.ok(applicationUpdate) + assert.doesNotMatch(applicationUpdate, /-Tags/) + assert.match(apply, /New-MgApplication[\s\S]*-Tags @\(\$scriptOwnerTag\)/) + assert.match( + redirects, + /if \(@\(\$Application\.Tags\) -contains \$scriptOwnerTag\)[\s\S]*return @\(\$CallbackUrl\)/, + ) + assert.match( + redirects, + /\$redirectUris = @\(\$Application\.Web\.RedirectUris\) \+ @\(\$CallbackUrl\)/, + ) + }) +}) diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts new file mode 100644 index 00000000..8a7f0059 --- /dev/null +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -0,0 +1,301 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function completeOidcAuthorization( + dependencies: AuthenticationDependencies, + code: string, + state: string, + browserToken: string, +): Promise<{ sessionToken?: string; purpose: AuthorizationPurpose }> { + const { now, oidc, oidcConfiguration, oidcProviderRevisionId, store } = dependencies + if (!oidcConfiguration?.enabled || !oidc || !oidcProviderRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "OIDC authentication is not configured.", + ) + } + const attempt = await store.lock((records) => + records.consumeAuthorizationAttempt(digestSecret(state)), + ) + if ( + !attempt || + !attempt.nonce || + !browserToken || + !secretsMatch(digestSecret(browserToken), attempt.browserDigest) || + attempt.authenticationProviderRevisionId !== oidcProviderRevisionId || + attempt.expiresAt <= now() + ) { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid or expired.", + ) + } + if (attempt.purpose === "link-identity") { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid.", + ) + } + const purpose: AuthorizationPurpose = attempt.purpose + let profile + try { + profile = await oidc.identify(code, attempt.pkceVerifier, attempt.nonce) + } catch { + await store.lock((records) => + records.audit({ + event: "authentication.provider_failed", + outcome: "failed", + actorUserId: null, + sessionId: attempt.originatingSessionId, + authenticationProviderRevisionId: oidcProviderRevisionId, + }), + ) + throw new AuthenticationError( + 503, + "authentication_provider_failed", + "The authentication provider failed.", + ) + } + const result = await store.lock(async (records) => { + const [currentProvider, stateRecord] = await Promise.all([ + records.currentAuthenticationProviderRevision("oidc"), + records.identityStoreState(), + ]) + if ( + currentProvider?.id !== oidcProviderRevisionId || + stateRecord.sessionGeneration !== attempt.sessionGeneration + ) { + throw new AuthenticationError( + 401, + "authorization_superseded", + "The authorization request was superseded.", + ) + } + if (purpose === "verify") { + const session = attempt.originatingSessionId + ? await records.sessionById(attempt.originatingSessionId) + : null + if ( + !session || + session.externalIdentity.provider !== "oidc" || + session.externalIdentity.issuer !== oidcConfiguration.issuer || + session.externalIdentity.subject !== profile.subject + ) { + throw new AuthenticationError(403, "identity_mismatch", "Verify the same OIDC identity.") + } + await records.verifySession(session.id, now()) + await records.audit({ + event: "session.verify", + outcome: "succeeded", + actorUserId: session.userId, + sessionId: session.id, + authenticationProviderRevisionId: oidcProviderRevisionId, + }) + return { purpose: "verify" as const } + } + const requiredRole = purpose === "bootstrap" || purpose === "recovery" ? "administrator" : null + if (!profile.role || (requiredRole && profile.role !== requiredRole)) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason: "required_role_missing" }, + }) + return { denied: true as const } + } + let identity = await records.externalIdentity("oidc", oidcConfiguration.issuer, profile.subject) + let membership = identity ? await records.membership(identity.userId) : null + const invitation = + purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" + ? await records.usableInvitation("oidc", oidcConfiguration.issuer, profile.subject, now()) + : null + if ( + purpose === "sign-in" && + (membership?.status === "suspended" || + (membership?.status === "removed" && !invitation) || + (!membership && + oidcConfiguration.membershipPolicy === "invitation-required" && + !invitation)) + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: membership?.status ?? "invitation_required" }, + }) + return { denied: true as const } + } + if (purpose === "bootstrap") { + const completed = + (await records.activeAdministratorCount()) === 0 && (await records.completeBootstrap(now())) + if (!completed) return { denied: true as const } + } else if (purpose === "recovery" && (await records.activeAdministratorCount()) > 0) { + return { denied: true as const } + } + if (!identity) { + const user = await records.createUser({ + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: oidcConfiguration.issuer, + subject: profile.subject, + username: profile.username, + email: profile.email, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } else { + await records.updateUser(identity.userId, { + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: identity.userId, + provider: "oidc", + issuer: oidcConfiguration.issuer, + subject: profile.subject, + username: profile.username, + email: profile.email, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } + membership = await records.membership(identity.userId) + if (purpose === "bootstrap" || purpose === "recovery") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "administrator", + source: purpose, + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: `membership.${purpose}`, + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + }) + } else if (invitation && membership?.status !== "suspended") { + const invitedRole = + invitation.role === "administrator" && profile.role === "administrator" + ? "administrator" + : "member" + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: invitedRole, + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + const consumed = await records.consumeInvitation( + invitation.id, + identity.id, + membership.id, + now(), + ) + if (!consumed) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation changed. Start again.", + ) + } + await records.audit({ + event: "membership.invitation.activate", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + invitationId: invitation.id, + }) + } else if (!membership) { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: profile.role, + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + } else if (membership.status === "active" && membership.role !== profile.role) { + if ( + membership.role === "administrator" && + profile.role === "member" && + (await records.activeAdministratorCount()) <= 1 + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + context: { reason: "last_administrator" }, + }) + return { denied: true as const } + } + membership = await records.saveMembership({ + userId: membership.userId, + status: membership.status, + role: profile.role, + source: membership.source, + suspendedAt: membership.suspendedAt, + removedAt: membership.removedAt, + }) + await records.audit({ + event: "membership.provider_role.refresh", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + context: { role: profile.role }, + }) + } + if (!membership || membership.status !== "active") return { denied: true as const } + const sessionToken = randomBytes(32).toString("base64url") + const session = await records.createSession({ + userId: identity.userId, + externalIdentityId: identity.id, + authenticationProviderRevisionId: oidcProviderRevisionId, + tokenDigest: digestSecret(sessionToken), + signedInAt: now(), + freshAuthorizedAt: now(), + }) + await records.audit({ + event: "session.sign_in", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: session.id, + authenticationProviderRevisionId: oidcProviderRevisionId, + }) + return { sessionToken, purpose, denied: false as const } + }) + if ("denied" in result && result.denied) { + throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + } + return result +} diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index ba846dba..1eaf2335 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -7,12 +7,15 @@ import { createAdministrationService } from "#server/domains/administration/crea import { createAuthenticationService } from "#server/domains/authentication/create-service" import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" +import { ensureOidcProviderRevision } from "#server/domains/authentication/ensure-oidc-provider-revision" +import { readOidcConfiguration } from "#server/domains/authentication/read-oidc-configuration" import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" import { createLocalArtifactStorage } from "#server/integrations/artifact-storage/create-local-storage" import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { createOidcClient } from "#server/integrations/oidc/create-client" import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" import { prisma } from "#server/integrations/prisma" @@ -70,7 +73,13 @@ function readHostSecret(environment: NodeJS.ProcessEnv, key: string): string | n export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv) { const origin = readOrigin(environment) - const configuration = readGitHubAuthentication(environment, origin) + const selectedProvider = environment.HUB_AUTH_PROVIDER + if (selectedProvider && !["github", "oidc"].includes(selectedProvider)) { + throw new Error("HUB_AUTH_PROVIDER must be github or oidc.") + } + const provider = selectedProvider === "oidc" ? "oidc" : "github" + const configuration = provider === "github" ? readGitHubAuthentication(environment, origin) : null + const oidcConfiguration = provider === "oidc" ? readOidcConfiguration(environment, origin) : null const store = createIdentityStore(prisma) const githubAuthorization = createGitHubAuthorizationService({ database: prisma, @@ -80,6 +89,9 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv const providerRevision = configuration ? await ensureGitHubProviderRevision(store, configuration) : null + const oidcProviderRevision = oidcConfiguration + ? await ensureOidcProviderRevision(store, oidcConfiguration) + : null const admissionPolicy = createAdmissionPolicy(environment, store, githubAuthorization) const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") @@ -89,7 +101,9 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv const settings: AuthenticationSettings = { origin: origin.origin, secureCookies: origin.protocol === "https:", - configured: configuration !== null, + configured: provider === "oidc" ? oidcConfiguration?.enabled === true : configuration !== null, + provider: provider === "oidc" ? "oidc" : configuration ? "github" : null, + providerLabel: provider === "oidc" ? "Microsoft Entra ID" : configuration ? "GitHub" : null, admissionPolicy: admissionPolicy.name, trustedProxyHops: readTrustedProxyHops(environment), } @@ -120,6 +134,10 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv github: configuration ? createGitHubUserAuthClient(configuration) : null, configuration, providerRevisionId: providerRevision?.id ?? null, + oidc: oidcConfiguration ? createOidcClient(oidcConfiguration) : null, + oidcConfiguration, + oidcProviderRevisionId: oidcProviderRevision?.id ?? null, + provider, admissionPolicy, bootstrapSecret, recoverySecret, diff --git a/hub/server/domains/authentication/create-service/index.ts b/hub/server/domains/authentication/create-service/index.ts index ea67bf62..a6873b07 100644 --- a/hub/server/domains/authentication/create-service/index.ts +++ b/hub/server/domains/authentication/create-service/index.ts @@ -1,4 +1,5 @@ import { completeGitHubAuthorization } from "#server/domains/authentication/complete-github-authorization" +import { completeOidcAuthorization } from "#server/domains/authentication/complete-oidc-authorization" import type { AuthenticationDependencies, AuthenticationService, @@ -7,6 +8,7 @@ import { getCurrentSession } from "#server/domains/authentication/get-current-se import { revokeSessions } from "#server/domains/authentication/revoke-sessions" import { signOut } from "#server/domains/authentication/sign-out" import { startGitHubAuthorization } from "#server/domains/authentication/start-github-authorization" +import { startOidcAuthorization } from "#server/domains/authentication/start-oidc-authorization" export function createAuthenticationService( options: Omit & { now?: () => Date }, @@ -16,8 +18,14 @@ export function createAuthenticationService( now: options.now ?? (() => new Date()), } return { - start: startGitHubAuthorization.bind(null, dependencies), - complete: completeGitHubAuthorization.bind(null, dependencies), + start: + dependencies.provider === "oidc" + ? startOidcAuthorization.bind(null, dependencies) + : startGitHubAuthorization.bind(null, dependencies), + complete: + dependencies.provider === "oidc" + ? completeOidcAuthorization.bind(null, dependencies) + : completeGitHubAuthorization.bind(null, dependencies), current: getCurrentSession.bind(null, dependencies), signOut: signOut.bind(null, dependencies), revokeAll: revokeSessions.bind(null, dependencies), diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts new file mode 100644 index 00000000..801c57a1 --- /dev/null +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -0,0 +1,557 @@ +import { describe, expect, it, vi } from "vitest" +import type { Mocked } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { + OidcClient, + OidcConfiguration, + OidcProfile, +} from "#server/integrations/oidc/create-client/types" +import type { ExternalIdentityInput } from "#server/repositories/external-identities/types" +import type { InstallationMembershipInput } from "#server/repositories/installation-memberships/types" +import { digestSecret } from "#server/utils/digest-secret" + +const now = new Date("2026-10-27T18:00:00.000Z") +const issuer = "https://login.example.test/tenant/v2.0" +const configuration: OidcConfiguration = { + issuer, + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "jit-assignment", + enabled: true, + providerRevision: 4, +} +const providerRevision = { + id: "oidc-revision-4", + authenticationProviderId: "oidc-provider", + revision: 4, + issuer, + configuration: {}, + authenticationProvider: { + id: "oidc-provider", + key: "oidc", + type: "oidc", + enabled: true, + }, +} + +type MockIdentityRecords = Mocked + +function records(overrides: Partial = {}) { + return Object.assign( + { + currentAuthenticationProviderRevision: vi.fn().mockResolvedValue(providerRevision), + identityStoreState: vi.fn().mockResolvedValue({ sessionGeneration: 7 }), + createAuthorizationAttempt: vi.fn().mockResolvedValue(undefined), + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(null), + externalIdentity: vi.fn().mockResolvedValue(null), + createUser: vi.fn().mockResolvedValue({ + id: "user", + displayName: "Member Name", + avatarUrl: null, + }), + updateUser: vi.fn().mockResolvedValue({ + id: "user", + displayName: "Member Name", + avatarUrl: null, + }), + saveExternalIdentity: vi.fn().mockImplementation((input: ExternalIdentityInput) => + Promise.resolve({ + id: "identity", + ...input, + email: input.email ?? null, + user: { + id: input.userId, + displayName: input.displayName, + avatarUrl: input.avatarUrl, + }, + }), + ), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + verifySession: vi.fn(), + deleteSession: vi.fn(), + deleteSessions: vi.fn(), + advanceSessionGeneration: vi.fn(), + session: vi.fn().mockResolvedValue(null), + sessionById: vi.fn().mockResolvedValue(null), + membership: vi.fn().mockResolvedValue(null), + usableInvitation: vi.fn().mockResolvedValue(null), + consumeInvitation: vi.fn().mockResolvedValue(true), + bootstrapCompletedAt: vi.fn().mockResolvedValue(null), + activeAdministratorCount: vi.fn().mockResolvedValue(0), + completeBootstrap: vi.fn().mockResolvedValue(true), + saveMembership: vi.fn().mockImplementation((input: InstallationMembershipInput) => + Promise.resolve({ + id: "membership", + suspendedAt: null, + removedAt: null, + ...input, + }), + ), + audit: vi.fn(), + }, + overrides, + ) +} + +type TestRecords = ReturnType + +function store(testRecords: TestRecords): IdentityStore { + const typedRecords = testRecords as unknown as IdentityRecords + return { + read: typedRecords, + lock: (operation) => operation(typedRecords), + } +} + +function profile(overrides: Partial = {}): OidcProfile { + return { + subject: "immutable-subject", + username: "member@example.test", + email: "member@example.test", + displayName: "Member Name", + avatarUrl: null, + role: "member" as const, + ...overrides, + } +} + +function oidc(overrides: Partial> = {}) { + return Object.assign( + { + authorizationUrl: vi.fn((state, verifier, nonce) => + Promise.resolve( + `https://login.example.test/authorize?state=${state}&verifier=${verifier}&nonce=${nonce}`, + ), + ), + identify: vi.fn().mockResolvedValue(profile()), + }, + overrides, + ) +} + +function attempt(overrides: Record = {}) { + return { + id: "attempt", + authenticationProviderRevisionId: providerRevision.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "one-use-nonce", + purpose: "sign-in" as const, + sessionGeneration: 7, + expiresAt: new Date(now.getTime() + 60_000), + ...overrides, + } +} + +function service( + testRecords: TestRecords, + client = oidc(), + overrides: Partial = {}, +) { + return { + client, + authentication: createAuthenticationService({ + store: store(testRecords), + github: null, + configuration: null, + providerRevisionId: null, + oidc: client, + oidcConfiguration: { ...configuration, ...overrides }, + oidcProviderRevisionId: providerRevision.id, + provider: "oidc", + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + now: () => now, + }), + } +} + +function activeMembership(role: "member" | "administrator" = "member") { + return { + id: "membership", + userId: "user", + status: "active" as const, + role, + source: "synchronized" as const, + suspendedAt: null, + removedAt: null, + } +} + +function existingIdentity() { + return { + id: "identity", + userId: "user", + provider: "oidc", + issuer, + subject: "immutable-subject", + username: "old-name@example.test", + email: "old-email@example.test", + displayName: "Old Name", + avatarUrl: null, + user: { id: "user", displayName: "Old Name", avatarUrl: null }, + } +} + +describe("createAuthenticationService() OIDC", () => { + it("starts a browser-bound attempt with one-use state, nonce, PKCE, and a ten-minute lifetime", async () => { + const identityRecords = records() + const { authentication, client } = service(identityRecords) + + const result = await authentication.start("sign-in") + + const url = new URL(result.authorizationUrl) + const state = url.searchParams.get("state") + const verifier = url.searchParams.get("verifier") + const nonce = url.searchParams.get("nonce") + expect(state).toMatch(/^[\w-]{43}$/) + expect(result.browserToken).toMatch(/^[\w-]{43}$/) + expect(verifier).toMatch(/^[\w-]{43}$/) + expect(nonce).toMatch(/^[\w-]{43}$/) + expect(client.authorizationUrl).toHaveBeenCalledWith(state, verifier, nonce) + expect(identityRecords.createAuthorizationAttempt).toHaveBeenCalledWith({ + authenticationProviderRevisionId: providerRevision.id, + originatingSessionId: null, + stateDigest: digestSecret(state!), + browserDigest: digestSecret(result.browserToken), + pkceVerifier: verifier, + nonce, + purpose: "sign-in", + sessionGeneration: 7, + expiresAt: new Date(now.getTime() + 10 * 60_000), + }) + }) + + it("consumes the attempt before rejecting the wrong browser, preventing a valid replay", async () => { + let available: ReturnType | null = attempt() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn(() => { + const result = available + available = null + return Promise.resolve(result) + }), + }) + const { authentication, client } = service(identityRecords) + + await expect(authentication.complete("code", "state", "wrong-browser")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + expect(client.identify).not.toHaveBeenCalled() + }) + + it.each([ + ["expired", attempt({ expiresAt: now })], + ["old provider revision", attempt({ authenticationProviderRevisionId: "superseded-revision" })], + ])("rejects an %s attempt before exchanging its code", async (_name, savedAttempt) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(savedAttempt), + }) + const { authentication, client } = service(identityRecords) + + await expect( + authentication.complete("provider-code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 401, code: "invalid_authorization" }) + expect(client.identify).not.toHaveBeenCalled() + }) + + it.each([ + [ + "provider changed", + { + currentAuthenticationProviderRevision: () => ({ + ...providerRevision, + id: "new-revision", + }), + sessionGeneration: 7, + }, + ], + [ + "sessions revoked", + { + currentAuthenticationProviderRevision: () => providerRevision, + sessionGeneration: 8, + }, + ], + ])("rejects an exchanged attempt after %s supersedes it", async (_name, scenario) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + currentAuthenticationProviderRevision: vi + .fn() + .mockResolvedValue(scenario.currentAuthenticationProviderRevision()), + identityStoreState: vi + .fn() + .mockResolvedValue({ sessionGeneration: scenario.sessionGeneration }), + }) + const { authentication } = service(identityRecords) + + await expect( + authentication.complete("provider-code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 401, code: "authorization_superseded" }) + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it.each([ + ["no mapped role", null], + ["an unknown role", null], + ])("denies sign-in with %s before persisting identity records", async (_name, role) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const client = oidc({ identify: vi.fn().mockResolvedValue(profile({ role })) }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it.each(["member", "administrator"] as const)( + "maps a new %s claim to an active synchronized membership", + async (role) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const client = oidc({ identify: vi.fn().mockResolvedValue(profile({ role })) }) + const { authentication } = service(identityRecords, client) + + const result = await authentication.complete("code", "state", "browser-token") + + expect(result.purpose).toBe("sign-in") + expect(result.sessionToken).toEqual(expect.any(String)) + expect(identityRecords.saveMembership).toHaveBeenCalledWith({ + userId: "user", + status: "active", + role, + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + }, + ) + + it("reuses the immutable subject while refreshing mutable username, email, and name", async () => { + const identity = existingIdentity() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(activeMembership()), + }) + const client = oidc({ + identify: vi.fn().mockResolvedValue( + profile({ + username: "renamed@example.test", + email: "new-email@example.test", + displayName: "New Name", + }), + ), + }) + const { authentication } = service(identityRecords, client) + + await authentication.complete("code", "state", "browser-token") + + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.updateUser).toHaveBeenCalledWith("user", { + displayName: "New Name", + avatarUrl: null, + }) + expect(identityRecords.saveExternalIdentity).toHaveBeenCalledWith({ + userId: "user", + provider: "oidc", + issuer, + subject: "immutable-subject", + username: "renamed@example.test", + email: "new-email@example.test", + displayName: "New Name", + avatarUrl: null, + }) + }) + + it("refuses a role refresh that would remove the final active administrator", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), + membership: vi.fn().mockResolvedValue(activeMembership("administrator")), + activeAdministratorCount: vi.fn().mockResolvedValue(1), + }) + const { authentication } = service(identityRecords) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + expect(identityRecords.audit).toHaveBeenCalledWith( + expect.objectContaining({ context: { reason: "last_administrator" } }), + ) + }) + + it("keeps a suspended member denied even when current provider claims still grant a role", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), + membership: vi.fn().mockResolvedValue({ + ...activeMembership(), + status: "suspended", + suspendedAt: now, + }), + }) + const { authentication } = service(identityRecords) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it("requires a fresh exact invitation before reactivating a removed member", async () => { + const removed = { + ...activeMembership(), + status: "removed" as const, + removedAt: now, + } + const identity = existingIdentity() + const deniedRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(removed), + }) + + await expect( + service(deniedRecords).authentication.complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) + + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "oidc", + issuer, + subject: "immutable-subject", + role: "member" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const invitedRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(removed), + usableInvitation: vi.fn().mockResolvedValue(invitation), + }) + + const invitedResult = await service(invitedRecords).authentication.complete( + "code", + "state", + "browser-token", + ) + expect(invitedResult.sessionToken).toEqual(expect.any(String)) + expect(invitedRecords.saveMembership).toHaveBeenCalledWith({ + userId: "user", + status: "active", + role: "member", + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + expect(invitedRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + }) + + it("keeps invitation-required admission distinct from just-in-time assignment", async () => { + const invitationRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + + await expect( + service(invitationRecords, oidc(), { + membershipPolicy: "invitation-required", + }).authentication.complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) + expect(invitationRecords.createUser).not.toHaveBeenCalled() + + const jitRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const jitResult = await service(jitRecords).authentication.complete( + "code", + "state", + "browser-token", + ) + expect(jitResult.sessionToken).toEqual(expect.any(String)) + expect(jitRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ source: "synchronized" }), + ) + }) + + it("stores only an opaque Hub session digest and never passes provider secrets to persistence or audit", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const client = oidc() + const { authentication } = service(identityRecords, client) + + const result = await authentication.complete( + "provider-authorization-code", + "state", + "browser-token", + ) + + expect(client.identify).toHaveBeenCalledWith( + "provider-authorization-code", + "pkce-verifier", + "one-use-nonce", + ) + expect(identityRecords.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + tokenDigest: digestSecret(result.sessionToken!), + authenticationProviderRevisionId: providerRevision.id, + }), + ) + const persistence = JSON.stringify([ + identityRecords.createUser.mock.calls, + identityRecords.saveExternalIdentity.mock.calls, + identityRecords.saveMembership.mock.calls, + identityRecords.createSession.mock.calls, + identityRecords.audit.mock.calls, + ]) + expect(persistence).not.toMatch( + /provider-authorization-code|client-secret-fixture|browser-token|pkce-verifier|one-use-nonce/, + ) + }) +}) diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts index 7c905fe5..4e4ab6ea 100644 --- a/hub/server/domains/authentication/create-service/types.ts +++ b/hub/server/domains/authentication/create-service/types.ts @@ -4,6 +4,7 @@ import type { GitHubUserAuthConfiguration, GitHubUserProfile, } from "#server/integrations/github/create-user-auth-client/types" +import type { OidcClient, OidcConfiguration } from "#server/integrations/oidc/create-client/types" import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" import type { GitHubAdmissionBaseline } from "#server/domains/github-authorization/create-service/types" import type { Prisma } from "../../../generated/prisma/client.ts" @@ -24,6 +25,8 @@ export interface AuthenticationSettings { origin: string secureCookies: boolean configured: boolean + provider?: "github" | "oidc" | null + providerLabel?: string | null admissionPolicy: AdmissionPolicy["name"] trustedProxyHops: number } @@ -53,6 +56,10 @@ export interface AuthenticationDependencies { github: GitHubUserAuthClient | null configuration: GitHubUserAuthConfiguration | null providerRevisionId: string | null + oidc?: OidcClient | null + oidcConfiguration?: OidcConfiguration | null + oidcProviderRevisionId?: string | null + provider?: "github" | "oidc" admissionPolicy: AdmissionPolicy bootstrapSecret?: string | null recoverySecret?: string | null diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts new file mode 100644 index 00000000..f7bb1a6e --- /dev/null +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest" + +import { ensureOidcProviderRevision } from "#server/domains/authentication/ensure-oidc-provider-revision" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +const configuration: OidcConfiguration = { + issuer: "https://login.example.test/tenant/v2.0", + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "invitation-required", + enabled: true, + providerRevision: 4, +} + +function storedConfiguration(overrides: Record = {}) { + return { + clientId: configuration.clientId, + callbackUrl: configuration.callbackUrl, + clientCredentialReference: configuration.clientCredentialReference, + credentialFingerprint: digestSecret(configuration.clientSecret), + scopes: configuration.scopes, + memberRole: configuration.memberRole, + administratorRole: configuration.administratorRole, + membershipPolicy: configuration.membershipPolicy, + ...overrides, + } +} + +function revision(overrides: Record = {}) { + return { + id: "revision-4", + authenticationProviderId: "provider", + revision: 4, + issuer: configuration.issuer, + configuration: storedConfiguration(), + authenticationProvider: { + id: "provider", + key: "oidc", + type: "oidc", + enabled: true, + }, + ...overrides, + } +} + +function store(latest: ReturnType | null) { + const createRevision = vi.fn() + const records = { + latestAuthenticationProviderRevision: vi.fn().mockResolvedValue(latest), + createAuthenticationProviderRevision: createRevision, + } as unknown as IdentityRecords + const identityStore: IdentityStore = { + read: records, + lock: (operation) => operation(records), + } + return { + records: records as IdentityRecords & { + latestAuthenticationProviderRevision: ReturnType + createAuthenticationProviderRevision: ReturnType + }, + createRevision, + identityStore, + } +} + +describe("ensureOidcProviderRevision()", () => { + it("reuses the same revision when issuer, enabled status, and nonsecret configuration match", async () => { + const existing = revision() + const fixture = store(existing) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).resolves.toBe( + existing, + ) + expect(fixture.createRevision).not.toHaveBeenCalled() + }) + + it("rejects configuration drift without an increased revision", async () => { + const fixture = store( + revision({ + configuration: storedConfiguration({ scopes: ["openid", "profile"] }), + }), + ) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).rejects.toThrow( + "HUB_OIDC_PROVIDER_REVISION must increase when OIDC configuration changes.", + ) + expect(fixture.createRevision).not.toHaveBeenCalled() + }) + + it("rejects a provider revision rollback", async () => { + const fixture = store(revision({ revision: 5 })) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).rejects.toThrow( + "HUB_OIDC_PROVIDER_REVISION cannot roll back an existing provider.", + ) + expect(fixture.createRevision).not.toHaveBeenCalled() + }) + + it("persists an increased revision with enabled status, reference, and fingerprint but no credential", async () => { + const fixture = store(revision({ revision: 3, id: "revision-3" })) + const created = revision() + fixture.createRevision.mockResolvedValue(created) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).resolves.toBe( + created, + ) + + expect(fixture.createRevision).toHaveBeenCalledWith({ + key: "oidc", + type: "oidc", + enabled: true, + revision: 4, + issuer: configuration.issuer, + configuration: storedConfiguration(), + }) + const persisted = JSON.stringify(fixture.createRevision.mock.calls) + expect(persisted).toContain(configuration.clientCredentialReference) + expect(persisted).toContain(digestSecret(configuration.clientSecret)) + expect(persisted).not.toContain(configuration.clientSecret) + }) +}) diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts new file mode 100644 index 00000000..0aa46b8c --- /dev/null +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts @@ -0,0 +1,51 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +function storedConfiguration(configuration: OidcConfiguration): Prisma.InputJsonObject { + return { + clientId: configuration.clientId, + callbackUrl: configuration.callbackUrl, + clientCredentialReference: configuration.clientCredentialReference, + credentialFingerprint: digestSecret(configuration.clientSecret), + scopes: configuration.scopes, + memberRole: configuration.memberRole, + administratorRole: configuration.administratorRole, + membershipPolicy: configuration.membershipPolicy, + } +} + +function matches(value: unknown, expected: Prisma.InputJsonObject): boolean { + return JSON.stringify(value) === JSON.stringify(expected) +} + +export function ensureOidcProviderRevision(store: IdentityStore, configuration: OidcConfiguration) { + return store.lock(async (records) => { + const latest = await records.latestAuthenticationProviderRevision("oidc") + const stored = storedConfiguration(configuration) + if (latest && latest.revision > configuration.providerRevision) { + throw new Error("HUB_OIDC_PROVIDER_REVISION cannot roll back an existing provider.") + } + if (latest?.revision === configuration.providerRevision) { + if ( + latest.authenticationProvider.type !== "oidc" || + latest.issuer !== configuration.issuer || + latest.authenticationProvider.enabled !== configuration.enabled || + !matches(latest.configuration, stored) + ) { + throw new Error("HUB_OIDC_PROVIDER_REVISION must increase when OIDC configuration changes.") + } + return latest + } + return records.createAuthenticationProviderRevision({ + key: "oidc", + type: "oidc", + enabled: configuration.enabled, + revision: configuration.providerRevision, + issuer: configuration.issuer, + configuration: stored, + }) + }) +} diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.test.ts b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts new file mode 100644 index 00000000..9dcf651a --- /dev/null +++ b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest" + +import { readOidcConfiguration } from "#server/domains/authentication/read-oidc-configuration" + +function environment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + HUB_OIDC_ENABLED: "true", + HUB_OIDC_ISSUER: "https://login.example.test/tenant/v2.0", + HUB_OIDC_CLIENT_ID: "hub-client", + HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE: "HUB_OIDC_CLIENT_SECRET", + HUB_OIDC_CLIENT_SECRET: "client-secret-fixture", + HUB_OIDC_CALLBACK_URL: "https://hub.example.test/api/auth/oidc/callback", + HUB_OIDC_SCOPES: "openid profile email", + HUB_OIDC_MEMBER_ROLE: "Hub.User", + HUB_OIDC_ADMINISTRATOR_ROLE: "Hub.Administrator", + HUB_OIDC_MEMBERSHIP_POLICY: "invitation-required", + HUB_OIDC_PROVIDER_REVISION: "4", + ...overrides, + } +} + +const origin = new URL("https://hub.example.test") + +describe("readOidcConfiguration()", () => { + it("returns a complete configuration with the credential resolved only through its reference", () => { + expect(readOidcConfiguration(environment(), origin)).toEqual({ + issuer: "https://login.example.test/tenant/v2.0", + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "invitation-required", + enabled: true, + providerRevision: 4, + }) + }) + + it("fails closed when the referenced credential is missing", () => { + const input = environment() + delete input.HUB_OIDC_CLIENT_SECRET + + expect(() => readOidcConfiguration(input, origin)).toThrow( + "Set HUB_OIDC_CLIENT_SECRET when HUB_AUTH_PROVIDER=oidc.", + ) + }) + + it("rejects a callback that does not exactly match the Hub OIDC callback", () => { + expect(() => + readOidcConfiguration( + environment({ + HUB_OIDC_CALLBACK_URL: "https://hub.example.test/api/auth/oidc/other-callback", + }), + origin, + ), + ).toThrow("HUB_OIDC_CALLBACK_URL must match the Hub OIDC callback URL.") + }) + + it("rejects an insecure issuer before accepting provider configuration", () => { + expect(() => + readOidcConfiguration( + environment({ HUB_OIDC_ISSUER: "http://login.example.test/tenant/v2.0" }), + origin, + ), + ).toThrow("HUB_OIDC_ISSUER must be an HTTPS URL") + }) + + it("rejects offline_access so Hub cannot receive a provider refresh token", () => { + expect(() => + readOidcConfiguration( + environment({ HUB_OIDC_SCOPES: "openid profile email offline_access" }), + origin, + ), + ).toThrow("HUB_OIDC_SCOPES must include openid and must not request offline_access.") + }) + + it("rejects an unknown membership policy instead of falling back to JIT admission", () => { + expect(() => + readOidcConfiguration( + environment({ HUB_OIDC_MEMBERSHIP_POLICY: "provider-default" }), + origin, + ), + ).toThrow("HUB_OIDC_MEMBERSHIP_POLICY must be jit-assignment or invitation-required.") + }) + + it.each(["0", "-1", "1.5", "not-a-number"])( + "rejects invalid provider revision %s", + (providerRevision) => { + expect(() => + readOidcConfiguration( + environment({ HUB_OIDC_PROVIDER_REVISION: providerRevision }), + origin, + ), + ).toThrow("HUB_OIDC_PROVIDER_REVISION must be a positive integer.") + }, + ) + + it("rejects the largest safe integer because a later provider change cannot increment it", () => { + expect(() => + readOidcConfiguration( + environment({ HUB_OIDC_PROVIDER_REVISION: String(Number.MAX_SAFE_INTEGER) }), + origin, + ), + ).toThrow("HUB_OIDC_PROVIDER_REVISION must be an incrementable positive integer.") + }) +}) diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.ts b/hub/server/domains/authentication/read-oidc-configuration/index.ts new file mode 100644 index 00000000..cf0eb81a --- /dev/null +++ b/hub/server/domains/authentication/read-oidc-configuration/index.ts @@ -0,0 +1,92 @@ +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" + +function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, key: string): string { + const value = environment[key] + if (!value) throw new Error(`Set ${key} when HUB_AUTH_PROVIDER=oidc.`) + return value +} + +export function readOidcConfiguration( + environment: NodeJS.ProcessEnv, + origin: URL, +): OidcConfiguration { + const enabledValue = requiredEnvironmentValue(environment, "HUB_OIDC_ENABLED") + if (!["true", "false"].includes(enabledValue)) { + throw new Error("HUB_OIDC_ENABLED must be true or false.") + } + const issuerUrl = new URL(requiredEnvironmentValue(environment, "HUB_OIDC_ISSUER")) + if ( + issuerUrl.protocol !== "https:" || + issuerUrl.username || + issuerUrl.password || + issuerUrl.search || + issuerUrl.hash + ) { + throw new Error("HUB_OIDC_ISSUER must be an HTTPS URL without credentials, query, or fragment.") + } + const issuer = issuerUrl.href.replace(/\/$/, "") + const callbackUrl = requiredEnvironmentValue(environment, "HUB_OIDC_CALLBACK_URL") + if (callbackUrl !== `${origin.origin}/api/auth/oidc/callback`) { + throw new Error("HUB_OIDC_CALLBACK_URL must match the Hub OIDC callback URL.") + } + const clientCredentialReference = requiredEnvironmentValue( + environment, + "HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE", + ) + if (!/^[A-Z][A-Z0-9_]{2,127}$/.test(clientCredentialReference)) { + throw new Error("HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE must name an environment variable.") + } + const clientSecret = requiredEnvironmentValue(environment, clientCredentialReference) + if (clientSecret.length < 16 || clientSecret.length > 4_096) { + throw new Error("The referenced OIDC client credential must contain 16 to 4096 characters.") + } + const scopes = requiredEnvironmentValue(environment, "HUB_OIDC_SCOPES") + .split(/\s+/) + .filter(Boolean) + if ( + !scopes.includes("openid") || + scopes.includes("offline_access") || + scopes.some((scope) => !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope)) + ) { + throw new Error("HUB_OIDC_SCOPES must include openid and must not request offline_access.") + } + const memberRole = requiredEnvironmentValue(environment, "HUB_OIDC_MEMBER_ROLE") + const administratorRole = requiredEnvironmentValue(environment, "HUB_OIDC_ADMINISTRATOR_ROLE") + if ( + memberRole === administratorRole || + memberRole.length > 255 || + administratorRole.length > 255 + ) { + throw new Error("OIDC member and administrator role values must be distinct and bounded.") + } + const membershipPolicy = requiredEnvironmentValue(environment, "HUB_OIDC_MEMBERSHIP_POLICY") + if (!["jit-assignment", "invitation-required"].includes(membershipPolicy)) { + throw new Error("HUB_OIDC_MEMBERSHIP_POLICY must be jit-assignment or invitation-required.") + } + const providerRevision = Number( + requiredEnvironmentValue(environment, "HUB_OIDC_PROVIDER_REVISION"), + ) + if (!Number.isSafeInteger(providerRevision) || providerRevision < 1) { + throw new Error("HUB_OIDC_PROVIDER_REVISION must be a positive integer.") + } + if (providerRevision >= Number.MAX_SAFE_INTEGER) { + throw new Error("HUB_OIDC_PROVIDER_REVISION must be an incrementable positive integer.") + } + const clientId = requiredEnvironmentValue(environment, "HUB_OIDC_CLIENT_ID") + if (clientId.length > 255) { + throw new Error("HUB_OIDC_CLIENT_ID must contain at most 255 characters.") + } + return { + issuer, + clientId, + clientSecret, + clientCredentialReference, + callbackUrl, + scopes, + memberRole, + administratorRole, + membershipPolicy: membershipPolicy as OidcConfiguration["membershipPolicy"], + enabled: enabledValue === "true", + providerRevision, + } +} diff --git a/hub/server/domains/authentication/start-oidc-authorization/index.ts b/hub/server/domains/authentication/start-oidc-authorization/index.ts new file mode 100644 index 00000000..1f34257d --- /dev/null +++ b/hub/server/domains/authentication/start-oidc-authorization/index.ts @@ -0,0 +1,114 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function startOidcAuthorization( + dependencies: AuthenticationDependencies, + purpose: AuthorizationPurpose, + sessionToken?: string, + enrollmentSecret?: string, +) { + const { + bootstrapSecret, + now, + oidc, + oidcConfiguration, + oidcProviderRevisionId, + recoverySecret, + store, + } = dependencies + if (!oidcConfiguration?.enabled || !oidc || !oidcProviderRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "OIDC authentication is not configured.", + ) + } + const state = randomBytes(32).toString("base64url") + const browserToken = randomBytes(32).toString("base64url") + const pkceVerifier = randomBytes(32).toString("base64url") + const nonce = randomBytes(32).toString("base64url") + const denial = await store.lock(async (records) => { + const provider = await records.currentAuthenticationProviderRevision("oidc") + if (provider?.id !== oidcProviderRevisionId) { + throw new AuthenticationError( + 409, + "provider_changed", + "OIDC authentication changed. Start again.", + ) + } + const session = + purpose === "verify" + ? await getCurrentSession(dependencies, sessionToken ?? "", records) + : null + const stateRecord = await records.identityStoreState() + if (purpose === "bootstrap") { + const reason = (await records.bootstrapCompletedAt()) + ? "already_completed" + : !bootstrapSecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) + ? "secret_mismatch" + : null + if (reason) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") + } + } + if (purpose === "recovery") { + const reason = !recoverySecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) + ? "secret_mismatch" + : (await records.activeAdministratorCount()) > 0 + ? "active_administrator_exists" + : null + if (reason) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") + } + } + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: oidcProviderRevisionId, + originatingSessionId: session?.id ?? null, + stateDigest: digestSecret(state), + browserDigest: digestSecret(browserToken), + pkceVerifier, + nonce, + purpose, + sessionGeneration: stateRecord.sessionGeneration, + expiresAt: new Date(now().getTime() + 10 * 60_000), + }) + return null + }) + if (denial) throw denial + return { + authorizationUrl: await oidc.authorizationUrl(state, pkceVerifier, nonce), + browserToken, + } +} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 896f2e9b..600a0c62 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -1,5 +1,6 @@ import { createAuthenticationProviderRevision } from "#server/repositories/authentication-providers/create-provider-revision" import { getCurrentAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-current-revision" +import { getLatestAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-latest-revision" import { getAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-revision" import { consumeAuthorizationAttempt } from "#server/repositories/authorization-attempts/consume-attempt" import { createAuthorizationAttempt } from "#server/repositories/authorization-attempts/create-attempt" @@ -74,6 +75,10 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident null, database, ), + latestAuthenticationProviderRevision: getLatestAuthenticationProviderRevision.bind( + null, + database, + ), authenticationProviderRevision: getAuthenticationProviderRevision.bind(null, database), session: getSession.bind(null, database), sessionById: getSessionById.bind(null, database), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index f6ca5760..f832cd84 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -88,6 +88,9 @@ export interface IdentityRecords { currentAuthenticationProviderRevision( key: string, ): Promise + latestAuthenticationProviderRevision( + key: string, + ): Promise authenticationProviderRevision(id: string): Promise session(tokenDigest: string): Promise sessionById(id: string): Promise diff --git a/hub/server/integrations/oidc/create-client/index.test.ts b/hub/server/integrations/oidc/create-client/index.test.ts new file mode 100644 index 00000000..272972ae --- /dev/null +++ b/hub/server/integrations/oidc/create-client/index.test.ts @@ -0,0 +1,331 @@ +import { calculateJwkThumbprint, exportJWK, generateKeyPair, SignJWT } from "jose" +import { describe, expect, it, vi } from "vitest" +import type { Mock } from "vitest" + +import { createOidcClient } from "#server/integrations/oidc/create-client" + +const issuer = "https://login.example.test/tenant/v2.0" +const configuration = { + issuer, + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "jit-assignment" as const, + enabled: true, + providerRevision: 1, +} + +async function signingKey() { + const pair = await generateKeyPair("RS256") + const jwk = await exportJWK(pair.publicKey) + const kid = await calculateJwkThumbprint(jwk) + return { ...pair, jwk: { ...jwk, kid, alg: "RS256", use: "sig" }, kid } +} + +async function idToken( + key: Awaited>, + overrides: Record = {}, +) { + const now = Math.floor(Date.now() / 1_000) + const claims = { + sub: "immutable-subject", + nonce: "expected-nonce", + iss: issuer, + aud: configuration.clientId, + roles: ["Hub.User"], + preferred_username: "member@example.test", + email: "member@example.test", + name: "Member Name", + picture: "https://images.example.test/member.png", + iat: now, + exp: now + 300, + ...overrides, + } + return new SignJWT(claims).setProtectedHeader({ alg: "RS256", kid: key.kid }).sign(key.privateKey) +} + +function json(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }) +} + +function providerFetch(options: { + token?: string + keys?: unknown[] + discovery?: Record +}): Mock { + return vi.fn((input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input) + if (url.pathname.endsWith("/.well-known/openid-configuration")) { + return Promise.resolve( + json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/keys`, + id_token_signing_alg_values_supported: ["RS256"], + ...options.discovery, + }), + ) + } + if (url.pathname.endsWith("/token")) return Promise.resolve(json({ id_token: options.token })) + if (url.pathname.endsWith("/keys")) { + return Promise.resolve(json({ keys: options.keys ?? [] })) + } + return Promise.resolve(json({}, 404)) + }) +} + +describe("createOidcClient()", () => { + it("rejects discovery metadata whose issuer is not the exact configured issuer", async () => { + const request = providerFetch({ + discovery: { issuer: `${issuer}/different` }, + }) + const client = createOidcClient(configuration, { fetch: request }) + + await expect(client.authorizationUrl("state", "pkce-verifier", "nonce")).rejects.toThrow( + "OIDC discovery returned a different issuer.", + ) + }) + + it.each([ + ["configured HTTP issuer", { ...configuration, issuer: "http://login.example.test/tenant" }], + ["configured loopback issuer", { ...configuration, issuer: "https://127.0.0.1/tenant" }], + ])("rejects a %s before making a provider request", (_name, unsafeConfiguration) => { + const request = vi.fn() + + expect(() => createOidcClient(unsafeConfiguration, { fetch: request })).toThrow( + "OIDC URLs must use a public HTTPS origin.", + ) + expect(request).not.toHaveBeenCalled() + }) + + it.each([ + ["HTTP authorization endpoint", { authorization_endpoint: "http://login.example.test/auth" }], + ["cross-origin token endpoint", { token_endpoint: "https://tokens.example.test/tenant/token" }], + ["IP-address JWKS endpoint", { jwks_uri: "https://192.0.2.1/keys" }], + ])("rejects discovery with a %s", async (_name, discovery) => { + const client = createOidcClient(configuration, { + fetch: providerFetch({ discovery }), + }) + + await expect(client.authorizationUrl("state", "pkce-verifier", "nonce")).rejects.toThrow( + "OIDC URLs must use a public HTTPS origin.", + ) + }) + + it.each([ + ["no advertised signing algorithms", { id_token_signing_alg_values_supported: [] }], + [ + "only symmetric signing algorithms", + { id_token_signing_alg_values_supported: ["HS256", "HS512"] }, + ], + ])("rejects discovery with %s", async (_name, discovery) => { + const client = createOidcClient(configuration, { + fetch: providerFetch({ discovery }), + }) + + await expect(client.authorizationUrl("state", "pkce-verifier", "nonce")).rejects.toThrow() + }) + + it("creates an authorization URL with the exact callback, scopes, state, nonce, and S256 PKCE", async () => { + const client = createOidcClient(configuration, { fetch: providerFetch({}) }) + + const url = new URL( + await client.authorizationUrl("opaque-state", "pkce-verifier", "one-use-nonce"), + ) + + expect(url.origin + url.pathname).toBe(`${issuer}/authorize`) + expect(Object.fromEntries(url.searchParams)).toEqual({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + response_type: "code", + response_mode: "query", + scope: "openid profile email", + state: "opaque-state", + nonce: "one-use-nonce", + code_challenge: "S-YYjGPeiHjsbIXpqrbVjcGUQn7X-4T468hBrBqm8pA", + code_challenge_method: "S256", + }) + }) + + it("exchanges the code with the verifier and maps administrator claims without returning tokens", async () => { + const key = await signingKey() + const token = await idToken(key, { + roles: ["Hub.User", "Hub.Administrator", 42], + }) + const request = providerFetch({ token, keys: [key.jwk] }) + const client = createOidcClient(configuration, { fetch: request }) + + const profile = await client.identify("one-use-code", "pkce-verifier", "expected-nonce") + + expect(profile).toEqual({ + subject: "immutable-subject", + username: "member@example.test", + email: "member@example.test", + displayName: "Member Name", + avatarUrl: "https://images.example.test/member.png", + role: "administrator", + }) + const tokenRequest = request.mock.calls.find(([input]) => + new URL(input instanceof Request ? input.url : input).pathname.endsWith("/token"), + ) + expect(tokenRequest).toBeDefined() + const init = tokenRequest?.[1] + expect(init?.method).toBe("POST") + if (!(init?.body instanceof URLSearchParams)) { + throw new Error("Expected the token request to use URL-encoded parameters.") + } + expect(init.body.toString()).toBe( + new URLSearchParams({ + grant_type: "authorization_code", + code: "one-use-code", + redirect_uri: configuration.callbackUrl, + client_id: configuration.clientId, + client_secret: configuration.clientSecret, + code_verifier: "pkce-verifier", + }).toString(), + ) + expect(JSON.stringify(profile)).not.toContain(token) + }) + + it.each([ + ["nonce", { nonce: "different-nonce" }], + ["audience", { aud: "different-client" }], + ["issuer", { iss: "https://different.example.test/tenant" }], + ["authorized party", { azp: "different-client" }], + ["multi-audience authorized party", { aud: ["hub-client", "other-client"] }], + ["issued-at", { iat: Math.floor(Date.now() / 1_000) + 60 }], + [ + "lifetime", + { + iat: Math.floor(Date.now() / 1_000) - 700, + exp: Math.floor(Date.now() / 1_000) + 300, + }, + ], + ])("rejects an ID token with an invalid %s claim", async (_name, claims) => { + const key = await signingKey() + const token = await idToken(key, claims) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [key.jwk] }), + }) + + await expect(client.identify("code", "verifier", "expected-nonce")).rejects.toThrow() + }) + + it("rejects an ID token signed by a key outside the provider JWKS", async () => { + const trusted = await signingKey() + const untrusted = await signingKey() + const token = await idToken(untrusted) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [trusted.jwk] }), + }) + + await expect(client.identify("code", "verifier", "expected-nonce")).rejects.toThrow() + }) + + it("reuses cached discovery and JWKS for known signing keys", async () => { + const key = await signingKey() + const token = await idToken(key) + const request = providerFetch({ token, keys: [key.jwk] }) + const client = createOidcClient(configuration, { fetch: request }) + + await client.identify("first-code", "first-verifier", "expected-nonce") + await client.identify("second-code", "second-verifier", "expected-nonce") + + const paths = request.mock.calls.map( + ([input]) => new URL(input instanceof Request ? input.url : input).pathname, + ) + expect(paths.filter((path) => path.endsWith("/.well-known/openid-configuration"))).toHaveLength( + 1, + ) + expect(paths.filter((path) => path.endsWith("/keys"))).toHaveLength(1) + expect(paths.filter((path) => path.endsWith("/token"))).toHaveLength(2) + }) + + it("refreshes JWKS once when a rotated token uses an unknown key id", async () => { + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(new Date("2026-10-27T18:00:00.000Z")) + const oldKey = await signingKey() + const rotatedKey = await signingKey() + const tokens = [await idToken(oldKey), await idToken(rotatedKey)] + let jwksRequests = 0 + const request = vi.fn((input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input) + if (url.pathname.endsWith("/.well-known/openid-configuration")) { + return Promise.resolve( + json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/keys`, + id_token_signing_alg_values_supported: ["RS256"], + }), + ) + } + if (url.pathname.endsWith("/token")) { + return Promise.resolve(json({ id_token: tokens.shift() })) + } + if (url.pathname.endsWith("/keys")) { + jwksRequests += 1 + return Promise.resolve( + json({ keys: jwksRequests === 1 ? [oldKey.jwk] : [oldKey.jwk, rotatedKey.jwk] }), + ) + } + return Promise.resolve(json({}, 404)) + }) + const client = createOidcClient(configuration, { fetch: request }) + + await client.identify("first-code", "first-verifier", "expected-nonce") + vi.advanceTimersByTime(5_001) + await client.identify("rotated-code", "rotated-verifier", "expected-nonce") + + expect(jwksRequests).toBe(2) + }) + + it.each(["discovery", "token", "jwks"] as const)( + "fails closed during a provider %s outage", + async (failedSurface) => { + const key = await signingKey() + const token = await idToken(key) + const request = vi.fn((input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input) + if ( + (failedSurface === "discovery" && + url.pathname.endsWith("/.well-known/openid-configuration")) || + (failedSurface === "token" && url.pathname.endsWith("/token")) || + (failedSurface === "jwks" && url.pathname.endsWith("/keys")) + ) { + return Promise.resolve(json({ error: "provider unavailable" }, 503)) + } + if (url.pathname.endsWith("/.well-known/openid-configuration")) { + return Promise.resolve( + json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/keys`, + id_token_signing_alg_values_supported: ["RS256"], + }), + ) + } + if (url.pathname.endsWith("/token")) return Promise.resolve(json({ id_token: token })) + if (url.pathname.endsWith("/keys")) return Promise.resolve(json({ keys: [key.jwk] })) + return Promise.resolve(json({}, 404)) + }) + const client = createOidcClient(configuration, { fetch: request }) + + const operation = + failedSurface === "discovery" + ? client.authorizationUrl("state", "verifier", "nonce") + : client.identify("code", "verifier", "expected-nonce") + await expect(operation).rejects.toThrow() + }, + ) +}) diff --git a/hub/server/integrations/oidc/create-client/index.ts b/hub/server/integrations/oidc/create-client/index.ts new file mode 100644 index 00000000..1340fb43 --- /dev/null +++ b/hub/server/integrations/oidc/create-client/index.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto" +import { isIP } from "node:net" + +import { createRemoteJWKSet, customFetch, jwtVerify } from "jose" +import { z } from "zod" + +import type { + OidcClient, + OidcClientOptions, + OidcConfiguration, + OidcProfile, +} from "#server/integrations/oidc/create-client/types" + +const discoverySchema = z.object({ + issuer: z.string(), + authorization_endpoint: z.string(), + token_endpoint: z.string(), + jwks_uri: z.string(), + id_token_signing_alg_values_supported: z.array(z.string()).min(1), +}) + +const tokenSchema = z.object({ + id_token: z.string().min(1), +}) + +const allowedSigningAlgorithms = new Set([ + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES512", + "EdDSA", +]) + +function publicHttpsUrl(value: string, expectedOrigin?: string): URL { + const url = new URL(value) + const hostname = url.hostname.toLowerCase() + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.hash || + (expectedOrigin && url.origin !== expectedOrigin) || + hostname === "localhost" || + hostname.endsWith(".localhost") || + isIP(hostname) !== 0 + ) { + throw new Error("OIDC URLs must use a public HTTPS origin.") + } + return url +} + +function codeChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url") +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null +} + +export function createOidcClient( + configuration: OidcConfiguration, + options: OidcClientOptions = {}, +): OidcClient { + const request = options.fetch ?? fetch + const timeoutMilliseconds = options.timeoutMilliseconds ?? 5_000 + const discoveryCacheMilliseconds = options.discoveryCacheMilliseconds ?? 5 * 60_000 + const configuredIssuer = publicHttpsUrl(configuration.issuer).href.replace(/\/$/, "") + let cachedDiscovery: + | { + expiresAt: number + issuer: string + authorizationEndpoint: URL + tokenEndpoint: URL + signingAlgorithms: string[] + jwks: ReturnType + } + | undefined + + async function fetchJson(url: URL, init?: RequestInit): Promise { + const response = await request(url, { + ...init, + redirect: "error", + signal: AbortSignal.timeout(timeoutMilliseconds), + }) + if (!response.ok) throw new Error(`OIDC request failed with status ${response.status}.`) + return response.json() + } + + async function discovery() { + if (cachedDiscovery && cachedDiscovery.expiresAt > Date.now()) return cachedDiscovery + const discoveryUrl = new URL(`${configuredIssuer}/.well-known/openid-configuration`) + const metadata = discoverySchema.parse(await fetchJson(discoveryUrl)) + if (metadata.issuer !== configuredIssuer) { + throw new Error("OIDC discovery returned a different issuer.") + } + const issuerOrigin = new URL(configuredIssuer).origin + const authorizationEndpoint = publicHttpsUrl(metadata.authorization_endpoint, issuerOrigin) + const tokenEndpoint = publicHttpsUrl(metadata.token_endpoint, issuerOrigin) + const jwksUrl = publicHttpsUrl(metadata.jwks_uri, issuerOrigin) + const signingAlgorithms = metadata.id_token_signing_alg_values_supported.filter((algorithm) => + allowedSigningAlgorithms.has(algorithm), + ) + if (signingAlgorithms.length === 0) { + throw new Error("OIDC discovery did not advertise a supported signing algorithm.") + } + cachedDiscovery = { + expiresAt: Date.now() + discoveryCacheMilliseconds, + issuer: configuredIssuer, + authorizationEndpoint, + tokenEndpoint, + signingAlgorithms, + jwks: createRemoteJWKSet(jwksUrl, { + cacheMaxAge: 10 * 60_000, + cooldownDuration: 5_000, + timeoutDuration: timeoutMilliseconds, + [customFetch]: request, + }), + } + return cachedDiscovery + } + + return { + async authorizationUrl(state, pkceVerifier, nonce) { + const metadata = await discovery() + const url = new URL(metadata.authorizationEndpoint) + url.search = new URLSearchParams({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + response_type: "code", + response_mode: "query", + scope: configuration.scopes.join(" "), + state, + nonce, + code_challenge: codeChallenge(pkceVerifier), + code_challenge_method: "S256", + }).toString() + return url.href + }, + async identify(code, pkceVerifier, nonce): Promise { + const metadata = await discovery() + const token = tokenSchema.parse( + await fetchJson(metadata.tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: configuration.callbackUrl, + client_id: configuration.clientId, + client_secret: configuration.clientSecret, + code_verifier: pkceVerifier, + }), + }), + ) + const verified = await jwtVerify(token.id_token, metadata.jwks, { + issuer: metadata.issuer, + audience: configuration.clientId, + algorithms: metadata.signingAlgorithms, + clockTolerance: 5, + maxTokenAge: "10m", + }) + const claims = verified.payload + if (!claims.sub || claims.nonce !== nonce) { + throw new Error("OIDC token subject or nonce is invalid.") + } + if ( + (claims.azp !== undefined && claims.azp !== configuration.clientId) || + (Array.isArray(claims.aud) && + claims.aud.length > 1 && + claims.azp !== configuration.clientId) + ) { + throw new Error("OIDC token authorized party is invalid.") + } + const roles = Array.isArray(claims.roles) + ? claims.roles.filter((role): role is string => typeof role === "string") + : [] + const role = roles.includes(configuration.administratorRole) + ? "administrator" + : roles.includes(configuration.memberRole) + ? "member" + : null + return { + subject: claims.sub, + username: + optionalString(claims.preferred_username) ?? + optionalString(claims.email) ?? + optionalString(claims.upn), + email: optionalString(claims.email) ?? optionalString(claims.preferred_username), + displayName: optionalString(claims.name), + avatarUrl: optionalString(claims.picture), + role, + } + }, + } +} diff --git a/hub/server/integrations/oidc/create-client/types.ts b/hub/server/integrations/oidc/create-client/types.ts new file mode 100644 index 00000000..841af9f8 --- /dev/null +++ b/hub/server/integrations/oidc/create-client/types.ts @@ -0,0 +1,33 @@ +export interface OidcConfiguration { + issuer: string + clientId: string + clientSecret: string + clientCredentialReference: string + callbackUrl: string + scopes: string[] + memberRole: string + administratorRole: string + membershipPolicy: "jit-assignment" | "invitation-required" + enabled: boolean + providerRevision: number +} + +export interface OidcProfile { + subject: string + username: string | null + email: string | null + displayName: string | null + avatarUrl: string | null + role: "member" | "administrator" | null +} + +export interface OidcClient { + authorizationUrl(state: string, pkceVerifier: string, nonce: string): Promise + identify(code: string, pkceVerifier: string, nonce: string): Promise +} + +export interface OidcClientOptions { + fetch?: typeof fetch + timeoutMilliseconds?: number + discoveryCacheMilliseconds?: number +} diff --git a/hub/server/repositories/authentication-providers/get-latest-revision/index.ts b/hub/server/repositories/authentication-providers/get-latest-revision/index.ts new file mode 100644 index 00000000..3e4a73dd --- /dev/null +++ b/hub/server/repositories/authentication-providers/get-latest-revision/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getLatestAuthenticationProviderRevision( + database: Prisma.TransactionClient, + key: string, +) { + return database.authenticationProviderRevision.findFirst({ + where: { authenticationProvider: { key } }, + orderBy: { revision: "desc" }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts index 638dab02..95123735 100644 --- a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -31,7 +31,9 @@ export async function consumeAuthorizationAttempt( if ( attempt.purpose !== "sign-in" && attempt.purpose !== "verify" && - attempt.purpose !== "link-identity" + attempt.purpose !== "link-identity" && + attempt.purpose !== "bootstrap" && + attempt.purpose !== "recovery" ) { throw new Error("Stored authorization purpose is invalid.") } diff --git a/hub/server/repositories/external-identities/save-external-identity/index.ts b/hub/server/repositories/external-identities/save-external-identity/index.ts index 9a386b2e..30df0d93 100644 --- a/hub/server/repositories/external-identities/save-external-identity/index.ts +++ b/hub/server/repositories/external-identities/save-external-identity/index.ts @@ -12,6 +12,7 @@ export function saveExternalIdentity( create: identity, update: { username: identity.username, + email: identity.email ?? null, displayName: identity.displayName, avatarUrl: identity.avatarUrl, }, diff --git a/hub/server/repositories/external-identities/types.ts b/hub/server/repositories/external-identities/types.ts index f35f00f4..e24db9f8 100644 --- a/hub/server/repositories/external-identities/types.ts +++ b/hub/server/repositories/external-identities/types.ts @@ -4,10 +4,12 @@ export interface ExternalIdentityInput { issuer: string subject: string username: string | null + email?: string | null displayName: string | null avatarUrl: string | null } export interface HubExternalIdentity extends ExternalIdentityInput { id: string + email: string | null } diff --git a/hub/server/routes/api/auth/oidc/callback/get/index.ts b/hub/server/routes/api/auth/oidc/callback/get/index.ts new file mode 100644 index 00000000..37e6a289 --- /dev/null +++ b/hub/server/routes/api/auth/oidc/callback/get/index.ts @@ -0,0 +1 @@ +export { get } from "#server/routes/api/auth/github/callback/get" diff --git a/hub/server/routes/api/auth/oidc/post/index.ts b/hub/server/routes/api/auth/oidc/post/index.ts new file mode 100644 index 00000000..f72cc858 --- /dev/null +++ b/hub/server/routes/api/auth/oidc/post/index.ts @@ -0,0 +1 @@ +export { post } from "#server/routes/api/auth/github/post" diff --git a/hub/server/routes/api/session/get/index.ts b/hub/server/routes/api/session/get/index.ts index 9f297743..2026ab95 100644 --- a/hub/server/routes/api/session/get/index.ts +++ b/hub/server/routes/api/session/get/index.ts @@ -23,6 +23,8 @@ export async function get( response.json({ authenticated: true, configured: authentication.settings.configured, + provider: authentication.settings.provider, + providerLabel: authentication.settings.providerLabel, admissionPolicy: authentication.settings.admissionPolicy, csrfToken, user: { @@ -48,6 +50,8 @@ export async function get( response.json({ authenticated: false, configured: authentication.settings.configured, + provider: authentication.settings.provider, + providerLabel: authentication.settings.providerLabel, admissionPolicy: authentication.settings.admissionPolicy, csrfToken, }) diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 4de27233..103daff0 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -16,6 +16,7 @@ function authentication( overrides: Partial> = {}, secureCookies = true, trustedProxyHops = 0, + provider: "github" | "oidc" = "github", ) { const service = Object.assign( { @@ -48,6 +49,8 @@ function authentication( origin: "https://hub.example", secureCookies, configured: true, + provider, + providerLabel: provider === "oidc" ? "Company SSO" : "GitHub", admissionPolicy: "hub-managed" as const, trustedProxyHops, }, @@ -114,6 +117,61 @@ describe("authentication routes", () => { expect(runtime.service.start).toHaveBeenCalledWith("sign-in") }) + it("uses the same protected initiation and opaque callback session contract for OIDC", async () => { + const runtime = authentication( + { + start: vi.fn().mockResolvedValue({ + authorizationUrl: + "https://login.example.test/tenant/authorize?state=state&nonce=nonce&code_challenge=challenge&code_challenge_method=S256", + browserToken, + }), + complete: vi.fn().mockResolvedValue({ + purpose: "sign-in", + sessionToken, + }), + }, + true, + 0, + "oidc", + ) + const baseUrl = await serve(runtime) + + const unprotected = await fetch(`${baseUrl}/api/auth/oidc`, { method: "POST" }) + expect(unprotected.status).toBe(403) + expect(runtime.service.start).not.toHaveBeenCalled() + + const initiation = await fetch(`${baseUrl}/api/auth/oidc`, { + method: "POST", + headers: csrfHeaders, + }) + expect(initiation.status).toBe(200) + expect(await initiation.json()).toEqual({ + authorizationUrl: + "https://login.example.test/tenant/authorize?state=state&nonce=nonce&code_challenge=challenge&code_challenge_method=S256", + }) + expect(initiation.headers.get("set-cookie")).toContain( + `__Host-replica-authorization=${browserToken}`, + ) + expect(runtime.service.start).toHaveBeenCalledWith("sign-in") + + const callback = await fetch( + `${baseUrl}/api/auth/oidc/callback?code=provider-code&state=${"s".repeat(43)}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + expect(callback.status).toBe(303) + expect(callback.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(callback.headers.get("set-cookie")).toContain(`__Host-replica-session=${sessionToken}`) + expect(callback.headers.get("set-cookie")).not.toContain("provider-code") + expect(runtime.service.complete).toHaveBeenCalledWith( + "provider-code", + "s".repeat(43), + browserToken, + ) + }) + it("rejects state-changing requests unless both the origin and CSRF token match", async () => { const runtime = authentication() const baseUrl = await serve(runtime) @@ -371,6 +429,8 @@ describe("authentication routes", () => { expect(body).toMatchObject({ authenticated: true, configured: true, + provider: "github", + providerLabel: "GitHub", admissionPolicy: "hub-managed", user: { id: "user", provider: "github", username: "octocat" }, session: { id: "session", freshAuthorizedAt: null }, diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 42f27f8f..a27ca1a1 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -6,6 +6,8 @@ import { respondAuthenticationError } from "#server/integrations/express/respond import { respondAdministrationError } from "#server/integrations/express/respond-administration-error" import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" +import { get as handleApiAuthOidcCallbackGet } from "#server/routes/api/auth/oidc/callback/get" +import { post as handleApiAuthOidcPost } from "#server/routes/api/auth/oidc/post" import { post as handleApiAuthEnrollmentPost } from "#server/routes/api/auth/enrollment/post" import { get as handleApiAdministrationGet } from "#server/routes/api/administration/get" import { post as handleApiAdministrationInvitationsPost } from "#server/routes/api/administration/invitations/post" @@ -74,6 +76,12 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi callbackLimit, handleApiAuthGithubCallbackGet.bind(null, authentication), ) + app.post("/api/auth/oidc", initiationLimit, handleApiAuthOidcPost.bind(null, authentication)) + app.get( + "/api/auth/oidc/callback", + callbackLimit, + handleApiAuthOidcCallbackGet.bind(null, authentication), + ) app.get("/api/session", handleApiSessionGet.bind(null, authentication)) app.delete("/api/session", handleApiSessionDelete.bind(null, authentication)) app.post( From 0b5d1cb5d39e578314cf6ba99c74dbc351a98b0e Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 16:44:13 -0700 Subject: [PATCH 39/56] fix(hub): normalize OIDC dependency source Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/package-lock.json b/hub/package-lock.json index 3cc47e3d..5048868b 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -6977,7 +6977,7 @@ }, "node_modules/jose": { "version": "6.2.12", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-6.2.12.tgz", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", "integrity": "sha1-ZWY+FG7dAQuY7Og/gXN9Oqlf0Nc=", "license": "MIT", "funding": { From b445e22028615f852ddc43571225a81dde297855 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 17:01:54 -0700 Subject: [PATCH 40/56] fix(hub): harden OIDC configuration lifecycle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 4 +- hub/docs/development.md | 2 +- hub/docs/entra-oidc.md | 4 +- hub/docs/identity.md | 2 +- hub/script/setup-entra-oidc.ps1 | 66 ++++++++++++++----- hub/script/tests/setup-entra-oidc.test.mjs | 52 +++++++++++++-- .../authentication/create-runtime/index.ts | 7 +- .../index.db.test.ts | 61 +++++++++++++++++ .../index.test.ts | 23 ++++++- .../ensure-oidc-provider-revision/index.ts | 35 +++++++++- .../index.test.ts | 21 ++++++ .../read-authentication-provider/index.ts | 7 ++ .../read-oidc-configuration/index.test.ts | 64 ++++++++++++++++-- .../read-oidc-configuration/index.ts | 18 +++-- 14 files changed, 316 insertions(+), 50 deletions(-) create mode 100644 hub/server/domains/authentication/ensure-oidc-provider-revision/index.db.test.ts create mode 100644 hub/server/domains/authentication/read-authentication-provider/index.test.ts create mode 100644 hub/server/domains/authentication/read-authentication-provider/index.ts diff --git a/hub/.env.example b/hub/.env.example index 066414a3..b70043f4 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -7,8 +7,8 @@ LOG_LEVEL=info # Public browser origin. Production requires HTTPS; local HTTP must use loopback. HUB_ORIGIN=http://127.0.0.1:5173 -# Select one user sign-in provider. Omit only to retain the existing optional GitHub setup. -# HUB_AUTH_PROVIDER=oidc +# Select exactly one user sign-in provider. Hub fails closed when this is absent. +HUB_AUTH_PROVIDER=github # Standards-based OIDC. The first documented profile is single-tenant Microsoft Entra ID. # The credential reference names another environment variable; Hub never stores the secret. diff --git a/hub/docs/development.md b/hub/docs/development.md index ac6e5dd1..571dd442 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -20,7 +20,7 @@ cp .env.example .env npm ci --engine-strict ``` -The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in, or select and configure the complete OIDC profile in [Microsoft Entra OIDC](entra-oidc.md). Leave both providers unconfigured to run Hub with sign-in disabled. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. +The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_AUTH_PROVIDER=github` with `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in, or select and configure the complete OIDC profile in [Microsoft Entra OIDC](entra-oidc.md). Keep `HUB_AUTH_PROVIDER=github` without GitHub credentials to run Hub with sign-in disabled. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. ## Start the app diff --git a/hub/docs/entra-oidc.md b/hub/docs/entra-oidc.md index 6cc3d660..84d4a02f 100644 --- a/hub/docs/entra-oidc.md +++ b/hub/docs/entra-oidc.md @@ -22,7 +22,7 @@ Install the listed Microsoft Graph PowerShell modules, sign in with the required -AdministratorGroupId ``` -Use `-Apply` with the same arguments to converge the application, service principal, assignment requirement, callback, `Hub.User` and `Hub.Administrator` app roles, and group assignments. Use `-ExistingApplicationId ` to converge an existing application without claiming removal ownership. `-Remove` deletes only resources tagged as created by this script and refuses unowned resources. +Use `-Apply` with the same arguments to converge the application, service principal, assignment requirement, callback, `Hub.User` and `Hub.Administrator` app roles, and group assignments. Apply adds missing requested groups and removes obsolete group assignments for those two managed roles; unrelated app roles and their assignments are preserved. Use `-ExistingApplicationId ` to converge an existing application without claiming removal ownership. `-Remove` deletes only resources tagged as created by this script and refuses unowned resources. The script prints nonsecret Hub configuration. It does not create or print a client credential. Create the credential through the tenant's approved process, place it in the secret store exposed by `HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE`, and protect it separately from the database. @@ -45,7 +45,7 @@ HUB_OIDC_MEMBERSHIP_POLICY=jit-assignment HUB_OIDC_PROVIDER_REVISION=1 ``` -The callback must exactly match `HUB_ORIGIN` plus `/api/auth/oidc/callback`. The issuer and discovered authorization, token, and JWKS endpoints must use the same public HTTPS origin. Increase `HUB_OIDC_PROVIDER_REVISION` whenever any OIDC setting or referenced credential changes. Hub rejects revision rollback and same-revision configuration drift. +The callback must exactly match `HUB_ORIGIN` plus `/api/auth/oidc/callback`. The first supported profile requires the issuer `https://login.microsoftonline.com//v2.0`; Hub does not accept a generic hostname or tenant alias. Discovered authorization, token, and JWKS endpoints must use that same HTTPS origin. Increase `HUB_OIDC_PROVIDER_REVISION` whenever any OIDC setting or referenced credential changes. Hub rejects revision rollback and same-revision configuration drift. `jit-assignment` creates a membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. `invitation-required` also requires a matching Hub invitation. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. An administrator claim may promote an active member. Hub refuses a claim-driven demotion that would remove the final active administrator. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 0e92d5dc..176a6c90 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -11,7 +11,7 @@ Hub maps `oidc + issuer/tenant + subject` to `ExternalIdentity`. Login, email, n ## GitHub user authentication -Set `HUB_ORIGIN`, `HUB_GITHUB_AUTH_CLIENT_ID`, and `HUB_GITHUB_AUTH_CLIENT_SECRET` to enable GitHub sign-in. `HUB_ORIGIN` must be an HTTPS origin, except that local development may use HTTP on loopback. The callback is always `${HUB_ORIGIN}/api/auth/github/callback`. +Set `HUB_AUTH_PROVIDER=github`, `HUB_ORIGIN`, `HUB_GITHUB_AUTH_CLIENT_ID`, and `HUB_GITHUB_AUTH_CLIENT_SECRET` to enable GitHub sign-in. `HUB_AUTH_PROVIDER` is always required; Hub does not infer a provider from whichever credentials happen to be present. `HUB_ORIGIN` must be an HTTPS origin, except that local development may use HTTP on loopback. The callback is always `${HUB_ORIGIN}/api/auth/github/callback`. Hub stores a versioned authentication-provider revision with non-secret configuration and a fingerprint that changes when the client secret changes. The client secret remains in the process environment. Each authorization attempt stores digested state and browser bindings, a PKCE verifier, the provider revision, the current session-revocation generation, its purpose, and a ten-minute expiry. diff --git a/hub/script/setup-entra-oidc.ps1 b/hub/script/setup-entra-oidc.ps1 index 782546c1..0271c63f 100644 --- a/hub/script/setup-entra-oidc.ps1 +++ b/hub/script/setup-entra-oidc.ps1 @@ -280,9 +280,8 @@ function Assert-GroupsExist { } } -function Get-MissingAssignments { +function Get-DesiredAssignments { param( - $ServicePrincipal, [string[]] $UserGroups, [string[]] $AdministratorGroups ) @@ -295,16 +294,40 @@ function Get-MissingAssignments { $desired += [pscustomobject] @{ GroupId = $groupId; RoleId = $administratorRoleId; Role = "Hub.Administrator" } } - $missing = @() - foreach ($assignment in $desired) { - $existing = @(Get-MgGroupAppRoleAssignment -GroupId $assignment.GroupId -All).Where({ - $_.ResourceId -eq $ServicePrincipal.Id -and $_.AppRoleId -eq $assignment.RoleId - }) - if ($existing.Count -eq 0) { - $missing += $assignment - } + return $desired +} + +function Get-AssignmentChanges { + param( + $ServicePrincipal, + [string[]] $UserGroups, + [string[]] $AdministratorGroups + ) + + $desired = @(Get-DesiredAssignments -UserGroups $UserGroups ` + -AdministratorGroups $AdministratorGroups) + $existing = @(Get-MgServicePrincipalAppRoleAssignedTo ` + -ServicePrincipalId $ServicePrincipal.Id -All) + $managed = @($existing).Where({ + $_.PrincipalType -eq "Group" -and + $_.AppRoleId -in @($userRoleId, $administratorRoleId) + }) + $missing = @($desired).Where({ + $candidate = $_ + @($managed).Where({ + $_.PrincipalId -eq $candidate.GroupId -and $_.AppRoleId -eq $candidate.RoleId + }).Count -eq 0 + }) + $obsolete = @($managed).Where({ + $candidate = $_ + @($desired).Where({ + $_.GroupId -eq $candidate.PrincipalId -and $_.RoleId -eq $candidate.AppRoleId + }).Count -eq 0 + }) + return [pscustomobject] @{ + Missing = $missing + Obsolete = $obsolete } - return $missing } function Invoke-Preflight { @@ -353,14 +376,19 @@ function Invoke-Plan { elseif (-not $servicePrincipal.AppRoleAssignmentRequired) { $actions += "Require assignment on the service principal." } - $missing = if ($servicePrincipal) { - Get-MissingAssignments -ServicePrincipal $servicePrincipal ` + $changes = if ($servicePrincipal) { + Get-AssignmentChanges -ServicePrincipal $servicePrincipal ` -UserGroups $UserGroupId -AdministratorGroups $AdministratorGroupId } - else { @() } - foreach ($assignment in $missing) { + else { [pscustomobject] @{ Missing = @(); Obsolete = @() } } + foreach ($assignment in $changes.Missing) { $actions += "Assign group '$($assignment.GroupId)' to $($assignment.Role)." } + foreach ($assignment in $changes.Obsolete) { + $role = if ($assignment.AppRoleId -eq $userRoleId) { "Hub.User" } ` + else { "Hub.Administrator" } + $actions += "Remove group '$($assignment.PrincipalId)' from $role." + } } if ($UserGroupId.Count -eq 0 -or $AdministratorGroupId.Count -eq 0) { @@ -420,14 +448,18 @@ function Invoke-Apply { $servicePrincipal.AppRoleAssignmentRequired = $true } - $missing = Get-MissingAssignments -ServicePrincipal $servicePrincipal ` + $changes = Get-AssignmentChanges -ServicePrincipal $servicePrincipal ` -UserGroups $UserGroupId -AdministratorGroups $AdministratorGroupId - foreach ($assignment in $missing) { + foreach ($assignment in $changes.Missing) { New-MgGroupAppRoleAssignment -GroupId $assignment.GroupId ` -PrincipalId $assignment.GroupId ` -ResourceId $servicePrincipal.Id ` -AppRoleId $assignment.RoleId | Out-Null } + foreach ($assignment in $changes.Obsolete) { + Remove-MgGroupAppRoleAssignment -GroupId $assignment.PrincipalId ` + -AppRoleAssignmentId $assignment.Id | Out-Null + } return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` -ApplicationId $application.AppId -CallbackUrl $CallbackUrl ` diff --git a/hub/script/tests/setup-entra-oidc.test.mjs b/hub/script/tests/setup-entra-oidc.test.mjs index df75f818..602ddc7d 100644 --- a/hub/script/tests/setup-entra-oidc.test.mjs +++ b/hub/script/tests/setup-entra-oidc.test.mjs @@ -41,10 +41,10 @@ describe("setup-entra-oidc.ps1", () => { assert.match(plan, /Group\.Read\.All/) }) - it("converges only drift and missing assignments", () => { + it("converges application drift and missing assignments", () => { const apply = functionBody("Invoke-Apply") const drift = functionBody("Test-ApplicationDrift") - const assignments = functionBody("Get-MissingAssignments") + const assignments = functionBody("Get-AssignmentChanges") assert.match(apply, /if \(-not \$application\)[\s\S]*New-MgApplication/) assert.match(apply, /if \(Test-ApplicationDrift[\s\S]*Update-MgApplication/) @@ -55,13 +55,55 @@ describe("setup-entra-oidc.ps1", () => { ) assert.match( assignments, - /\$_.ResourceId -eq \$ServicePrincipal\.Id -and \$_.AppRoleId -eq \$assignment\.RoleId/, + /\$_.PrincipalId -eq \$candidate\.GroupId -and \$_.AppRoleId -eq \$candidate\.RoleId/, + ) + assert.match( + apply, + /foreach \(\$assignment in \$changes\.Missing\)[\s\S]*New-MgGroupAppRoleAssignment/, ) - assert.match(apply, /foreach \(\$assignment in \$missing\)[\s\S]*New-MgGroupAppRoleAssignment/) assert.match(drift, /AzureADMyOrg/) assert.match(drift, /Compare-Object \$actualRedirectUris \$expectedRedirectUris/) assert.match(apply, /Assert-GroupsExist[\s\S]*Resolve-Application/) - assert.match(apply, /Get-MissingAssignments[\s\S]*foreach \(\$assignment in \$missing\)/) + assert.match( + apply, + /Get-AssignmentChanges[\s\S]*foreach \(\$assignment in \$changes\.Missing\)/, + ) + }) + + it("limits obsolete assignments to managed group roles and preserves unrelated assignments", () => { + const assignments = functionBody("Get-AssignmentChanges") + + assert.match(assignments, /Get-MgServicePrincipalAppRoleAssignedTo/) + assert.match(assignments, /\$_.PrincipalType -eq "Group"/) + assert.match(assignments, /\$_.AppRoleId -in @\(\$userRoleId, \$administratorRoleId\)/) + assert.match(assignments, /\$obsolete = @\(\$managed\)\.Where/) + assert.doesNotMatch(assignments, /\$obsolete = @\(\$existing\)\.Where/) + }) + + it("reports both missing and obsolete managed assignments in the plan", () => { + const plan = functionBody("Invoke-Plan") + + assert.match( + plan, + /foreach \(\$assignment in \$changes\.Missing\)[\s\S]*Assign group '\$\(\$assignment\.GroupId\)'/, + ) + assert.match( + plan, + /foreach \(\$assignment in \$changes\.Obsolete\)[\s\S]*Remove group '\$\(\$assignment\.PrincipalId\)'/, + ) + assert.match(plan, /if \(\$assignment\.AppRoleId -eq \$userRoleId\) \{ "Hub\.User" \}/) + assert.match(plan, /else \{ "Hub\.Administrator" \}/) + }) + + it("removes obsolete managed group assignments during apply", () => { + const apply = functionBody("Invoke-Apply") + + assert.match( + apply, + /foreach \(\$assignment in \$changes\.Obsolete\)[\s\S]*Remove-MgGroupAppRoleAssignment/, + ) + assert.match(apply, /-GroupId \$assignment\.PrincipalId/) + assert.match(apply, /-AppRoleAssignmentId \$assignment\.Id/) }) it("fails closed before removing unowned resources", () => { diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 1eaf2335..4993862a 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -8,6 +8,7 @@ import { createAuthenticationService } from "#server/domains/authentication/crea import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" import { ensureOidcProviderRevision } from "#server/domains/authentication/ensure-oidc-provider-revision" +import { readAuthenticationProvider } from "#server/domains/authentication/read-authentication-provider" import { readOidcConfiguration } from "#server/domains/authentication/read-oidc-configuration" import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" @@ -73,11 +74,7 @@ function readHostSecret(environment: NodeJS.ProcessEnv, key: string): string | n export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv) { const origin = readOrigin(environment) - const selectedProvider = environment.HUB_AUTH_PROVIDER - if (selectedProvider && !["github", "oidc"].includes(selectedProvider)) { - throw new Error("HUB_AUTH_PROVIDER must be github or oidc.") - } - const provider = selectedProvider === "oidc" ? "oidc" : "github" + const provider = readAuthenticationProvider(environment) const configuration = provider === "github" ? readGitHubAuthentication(environment, origin) : null const oidcConfiguration = provider === "oidc" ? readOidcConfiguration(environment, origin) : null const store = createIdentityStore(prisma) diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.db.test.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.db.test.ts new file mode 100644 index 00000000..cd325acb --- /dev/null +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.db.test.ts @@ -0,0 +1,61 @@ +import { PrismaPg } from "@prisma/adapter-pg" +import { describe, expect, it, onTestFinished } from "vitest" + +import { ensureOidcProviderRevision } from "#server/domains/authentication/ensure-oidc-provider-revision" +import { createIdentityStore } from "#server/domains/identity/create-store" +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" + +import { PrismaClient } from "../../../generated/prisma/client.ts" + +const configuration: OidcConfiguration = { + issuer: "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "invitation-required", + enabled: true, + providerRevision: 4, +} + +function databaseClient() { + const connectionString = process.env.DATABASE_URL + if (!connectionString) throw new Error("Set DATABASE_URL before running database tests.") + return new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) +} + +describe("ensureOidcProviderRevision() database behavior", () => { + it("reuses a JSONB-backed revision after a process restart and scope reordering", async () => { + const firstClient = databaseClient() + let restartedClient: PrismaClient | null = null + onTestFinished(async () => { + await Promise.allSettled([ + firstClient.$disconnect(), + ...(restartedClient ? [restartedClient.$disconnect()] : []), + ]) + }) + + const created = await ensureOidcProviderRevision( + createIdentityStore(firstClient), + configuration, + ) + await firstClient.$disconnect() + + restartedClient = databaseClient() + const restartedConfiguration = { + ...configuration, + scopes: ["email", "openid", "profile"], + } + + await expect( + ensureOidcProviderRevision(createIdentityStore(restartedClient), restartedConfiguration), + ).resolves.toMatchObject({ + id: created.id, + revision: configuration.providerRevision, + }) + await expect(restartedClient.authenticationProviderRevision.count()).resolves.toBe(1) + }) +}) diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts index f7bb1a6e..1ef05645 100644 --- a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts @@ -25,7 +25,7 @@ function storedConfiguration(overrides: Record = {}) { callbackUrl: configuration.callbackUrl, clientCredentialReference: configuration.clientCredentialReference, credentialFingerprint: digestSecret(configuration.clientSecret), - scopes: configuration.scopes, + scopes: [...configuration.scopes].sort(), memberRole: configuration.memberRole, administratorRole: configuration.administratorRole, membershipPolicy: configuration.membershipPolicy, @@ -81,6 +81,27 @@ describe("ensureOidcProviderRevision()", () => { expect(fixture.createRevision).not.toHaveBeenCalled() }) + it("reuses the same revision when stored JSON keys and scopes have a different order", async () => { + const existing = revision({ + configuration: { + membershipPolicy: configuration.membershipPolicy, + administratorRole: configuration.administratorRole, + memberRole: configuration.memberRole, + scopes: ["profile", "email", "openid"], + credentialFingerprint: digestSecret(configuration.clientSecret), + clientCredentialReference: configuration.clientCredentialReference, + callbackUrl: configuration.callbackUrl, + clientId: configuration.clientId, + }, + }) + const fixture = store(existing) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).resolves.toBe( + existing, + ) + expect(fixture.createRevision).not.toHaveBeenCalled() + }) + it("rejects configuration drift without an increased revision", async () => { const fixture = store( revision({ diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts index 0aa46b8c..a048bf08 100644 --- a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts @@ -1,16 +1,34 @@ import type { IdentityStore } from "#server/domains/identity/create-store/types" import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" import { digestSecret } from "#server/utils/digest-secret" +import { z } from "zod" import type { Prisma } from "../../../generated/prisma/client.ts" +const storedConfigurationSchema = z + .object({ + clientId: z.string(), + callbackUrl: z.string(), + clientCredentialReference: z.string(), + credentialFingerprint: z.string(), + scopes: z.array(z.string()), + memberRole: z.string(), + administratorRole: z.string(), + membershipPolicy: z.enum(["jit-assignment", "invitation-required"]), + }) + .strict() + +function canonicalScopes(scopes: string[]): string[] { + return [...new Set(scopes)].sort() +} + function storedConfiguration(configuration: OidcConfiguration): Prisma.InputJsonObject { return { clientId: configuration.clientId, callbackUrl: configuration.callbackUrl, clientCredentialReference: configuration.clientCredentialReference, credentialFingerprint: digestSecret(configuration.clientSecret), - scopes: configuration.scopes, + scopes: canonicalScopes(configuration.scopes), memberRole: configuration.memberRole, administratorRole: configuration.administratorRole, membershipPolicy: configuration.membershipPolicy, @@ -18,7 +36,20 @@ function storedConfiguration(configuration: OidcConfiguration): Prisma.InputJson } function matches(value: unknown, expected: Prisma.InputJsonObject): boolean { - return JSON.stringify(value) === JSON.stringify(expected) + const parsedValue = storedConfigurationSchema.safeParse(value) + const parsedExpected = storedConfigurationSchema.safeParse(expected) + if (!parsedValue.success || !parsedExpected.success) return false + return ( + parsedValue.data.clientId === parsedExpected.data.clientId && + parsedValue.data.callbackUrl === parsedExpected.data.callbackUrl && + parsedValue.data.clientCredentialReference === parsedExpected.data.clientCredentialReference && + parsedValue.data.credentialFingerprint === parsedExpected.data.credentialFingerprint && + parsedValue.data.memberRole === parsedExpected.data.memberRole && + parsedValue.data.administratorRole === parsedExpected.data.administratorRole && + parsedValue.data.membershipPolicy === parsedExpected.data.membershipPolicy && + canonicalScopes(parsedValue.data.scopes).join("\0") === + canonicalScopes(parsedExpected.data.scopes).join("\0") + ) } export function ensureOidcProviderRevision(store: IdentityStore, configuration: OidcConfiguration) { diff --git a/hub/server/domains/authentication/read-authentication-provider/index.test.ts b/hub/server/domains/authentication/read-authentication-provider/index.test.ts new file mode 100644 index 00000000..80e3e2af --- /dev/null +++ b/hub/server/domains/authentication/read-authentication-provider/index.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" + +import { readAuthenticationProvider } from "#server/domains/authentication/read-authentication-provider" + +describe("readAuthenticationProvider()", () => { + it.each(["github", "oidc"] as const)( + "returns the explicitly configured %s provider", + (provider) => { + expect(readAuthenticationProvider({ HUB_AUTH_PROVIDER: provider })).toBe(provider) + }, + ) + + it.each([undefined, "", "GitHub", "oidc ", "other"])( + "rejects a missing or unsupported provider value %s", + (provider) => { + expect(() => readAuthenticationProvider({ HUB_AUTH_PROVIDER: provider })).toThrow( + "Set HUB_AUTH_PROVIDER to github or oidc.", + ) + }, + ) +}) diff --git a/hub/server/domains/authentication/read-authentication-provider/index.ts b/hub/server/domains/authentication/read-authentication-provider/index.ts new file mode 100644 index 00000000..a8857c67 --- /dev/null +++ b/hub/server/domains/authentication/read-authentication-provider/index.ts @@ -0,0 +1,7 @@ +export function readAuthenticationProvider(environment: NodeJS.ProcessEnv): "github" | "oidc" { + const provider = environment.HUB_AUTH_PROVIDER + if (provider !== "github" && provider !== "oidc") { + throw new Error("Set HUB_AUTH_PROVIDER to github or oidc.") + } + return provider +} diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.test.ts b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts index 9dcf651a..eacbf30b 100644 --- a/hub/server/domains/authentication/read-oidc-configuration/index.test.ts +++ b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts @@ -5,7 +5,7 @@ import { readOidcConfiguration } from "#server/domains/authentication/read-oidc- function environment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { HUB_OIDC_ENABLED: "true", - HUB_OIDC_ISSUER: "https://login.example.test/tenant/v2.0", + HUB_OIDC_ISSUER: "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", HUB_OIDC_CLIENT_ID: "hub-client", HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE: "HUB_OIDC_CLIENT_SECRET", HUB_OIDC_CLIENT_SECRET: "client-secret-fixture", @@ -24,7 +24,7 @@ const origin = new URL("https://hub.example.test") describe("readOidcConfiguration()", () => { it("returns a complete configuration with the credential resolved only through its reference", () => { expect(readOidcConfiguration(environment(), origin)).toEqual({ - issuer: "https://login.example.test/tenant/v2.0", + issuer: "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", clientId: "hub-client", clientSecret: "client-secret-fixture", clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", @@ -38,6 +38,20 @@ describe("readOidcConfiguration()", () => { }) }) + it("normalizes tenant UUID case and a trailing issuer slash", () => { + const configuration = readOidcConfiguration( + environment({ + HUB_OIDC_ISSUER: + "https://login.microsoftonline.com/A0B1C2D3-E4F5-4A67-8B9C-0D1E2F3A4B5C/v2.0/", + }), + origin, + ) + + expect(configuration.issuer).toBe( + "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ) + }) + it("fails closed when the referenced credential is missing", () => { const input = environment() delete input.HUB_OIDC_CLIENT_SECRET @@ -61,10 +75,52 @@ describe("readOidcConfiguration()", () => { it("rejects an insecure issuer before accepting provider configuration", () => { expect(() => readOidcConfiguration( - environment({ HUB_OIDC_ISSUER: "http://login.example.test/tenant/v2.0" }), + environment({ + HUB_OIDC_ISSUER: + "http://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + }), + origin, + ), + ).toThrow("HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.") + }) + + it.each([ + [ + "a generic provider host", + "https://login.example.test/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ], + ["a private address", "https://10.0.0.1/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0"], + [ + "a DNS rebinding-capable host", + "https://127.0.0.1.nip.io/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ], + [ + "a Microsoft login alias", + "https://login.windows.net/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ], + [ + "an explicit port", + "https://login.microsoftonline.com:443/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ], + ])("rejects %s", (_description, issuer) => { + expect(() => readOidcConfiguration(environment({ HUB_OIDC_ISSUER: issuer }), origin)).toThrow( + "HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.", + ) + }) + + it.each([ + ["a tenant alias", "organizations"], + ["a non-UUID tenant", "tenant"], + ["a truncated UUID", "a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5"], + ])("rejects %s in the tenant segment", (_description, tenant) => { + expect(() => + readOidcConfiguration( + environment({ + HUB_OIDC_ISSUER: `https://login.microsoftonline.com/${tenant}/v2.0`, + }), origin, ), - ).toThrow("HUB_OIDC_ISSUER must be an HTTPS URL") + ).toThrow("HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.") }) it("rejects offline_access so Hub cannot receive a provider refresh token", () => { diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.ts b/hub/server/domains/authentication/read-oidc-configuration/index.ts index cf0eb81a..9d3a8358 100644 --- a/hub/server/domains/authentication/read-oidc-configuration/index.ts +++ b/hub/server/domains/authentication/read-oidc-configuration/index.ts @@ -14,17 +14,15 @@ export function readOidcConfiguration( if (!["true", "false"].includes(enabledValue)) { throw new Error("HUB_OIDC_ENABLED must be true or false.") } - const issuerUrl = new URL(requiredEnvironmentValue(environment, "HUB_OIDC_ISSUER")) - if ( - issuerUrl.protocol !== "https:" || - issuerUrl.username || - issuerUrl.password || - issuerUrl.search || - issuerUrl.hash - ) { - throw new Error("HUB_OIDC_ISSUER must be an HTTPS URL without credentials, query, or fragment.") + const issuerValue = requiredEnvironmentValue(environment, "HUB_OIDC_ISSUER") + const issuerMatch = issuerValue.match( + /^https:\/\/login\.microsoftonline\.com\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/v2\.0\/?$/i, + ) + const tenantId = issuerMatch?.[1] + if (!tenantId) { + throw new Error("HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.") } - const issuer = issuerUrl.href.replace(/\/$/, "") + const issuer = `https://login.microsoftonline.com/${tenantId.toLowerCase()}/v2.0` const callbackUrl = requiredEnvironmentValue(environment, "HUB_OIDC_CALLBACK_URL") if (callbackUrl !== `${origin.origin}/api/auth/oidc/callback`) { throw new Error("HUB_OIDC_CALLBACK_URL must match the Hub OIDC callback URL.") From a078d7de3034ad73a059180143e0d97c21f5f4e2 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 10:52:52 -0700 Subject: [PATCH 41/56] fix(hub): accept long OIDC callback codes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/identity.md | 3 +- .../create-service/oidc.test.ts | 43 ++++ .../routes/api/auth/callback/get/index.ts | 65 ++++++ .../api/auth/github/callback/get/index.ts | 46 +--- .../api/auth/oidc/callback/get/index.ts | 20 +- hub/server/routes/authentication.test.ts | 215 ++++++++++++++++++ 6 files changed, 352 insertions(+), 40 deletions(-) create mode 100644 hub/server/routes/api/auth/callback/get/index.ts diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 176a6c90..9f5bc8c8 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -6,7 +6,8 @@ Hub uses one configured user sign-in provider to prove an external identity. Hub Hub supports standards-based OIDC authorization code flow as a sibling of GitHub user authorization. The first deployment profile is single-tenant Microsoft Entra ID with `Hub.User` and `Hub.Administrator` app roles. See [Microsoft Entra OIDC](entra-oidc.md) for the setup script, exact configuration, assignment policy, and operational boundary. -OIDC discovery must return the exact configured issuer. Hub binds state to the initiating browser, uses PKCE and nonce, consumes attempts once, exchanges the code with a bounded timeout, verifies the ID-token signature through cached rotating JWKS, and validates issuer, audience, authorized party, token lifetime, and nonce. The configured issuer is host configuration, never request input. Hub rejects non-public or cross-origin discovery endpoints. +OIDC discovery must return the exact configured issuer. Hub binds state to the initiating browser, uses PKCE and nonce, consumes attempts once, exchanges the code with a bounded timeout, verifies the ID-token signature through cached rotating JWKS, and validates issuer, audience, authorized party, token lifetime, and nonce. The configured issuer is host configuration, never request input. Hub rejects non-public or cross-origin discovery endpoints. OIDC callbacks accept one query-string authorization code up to 8,192 characters and one exact 43-character base64url state. GitHub keeps its 1,000-character code limit. Duplicate, missing, or oversized values fail before exchange, with only a stable nonsecret failure reason returned to the browser. Hub requests `response_mode=query`; `form_post` is not enabled. + Hub maps `oidc + issuer/tenant + subject` to `ExternalIdentity`. Login, email, name, and picture are mutable display attributes. They never admit a user or select an existing identity. The authorization response and provider tokens are discarded after identity and app-role extraction. ## GitHub user authentication diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index 801c57a1..f647df81 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -263,6 +263,49 @@ describe("createAuthenticationService() OIDC", () => { expect(client.identify).not.toHaveBeenCalled() }) + it("consumes a long-code attempt once before exchanging it and rejects a replay", async () => { + const longCode = `oidc.${"Ab9_-".repeat(410)}`.slice(0, 2_048) + let available: ReturnType | null = attempt() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn(() => { + const result = available + available = null + return Promise.resolve(result) + }), + }) + const client = oidc() + const { authentication } = service(identityRecords, client) + + const result = await authentication.complete(longCode, "state", "browser-token") + await expect(authentication.complete(longCode, "state", "browser-token")).rejects.toMatchObject( + { + status: 401, + code: "invalid_authorization", + }, + ) + + expect(result.sessionToken).toEqual(expect.any(String)) + expect(identityRecords.consumeAuthorizationAttempt).toHaveBeenCalledTimes(2) + expect(identityRecords.consumeAuthorizationAttempt).toHaveBeenNthCalledWith( + 1, + digestSecret("state"), + ) + expect(client.identify).toHaveBeenCalledOnce() + expect(client.identify).toHaveBeenCalledWith(longCode, "pkce-verifier", "one-use-nonce") + expect(identityRecords.consumeAuthorizationAttempt.mock.invocationCallOrder[0]).toBeLessThan( + client.identify.mock.invocationCallOrder[0]!, + ) + expect( + JSON.stringify([ + identityRecords.createUser.mock.calls, + identityRecords.saveExternalIdentity.mock.calls, + identityRecords.saveMembership.mock.calls, + identityRecords.createSession.mock.calls, + identityRecords.audit.mock.calls, + ]), + ).not.toContain(longCode) + }) + it.each([ ["expired", attempt({ expiresAt: now })], ["old provider revision", attempt({ authenticationProviderRevisionId: "superseded-revision" })], diff --git a/hub/server/routes/api/auth/callback/get/index.ts b/hub/server/routes/api/auth/callback/get/index.ts new file mode 100644 index 00000000..c296b6c1 --- /dev/null +++ b/hub/server/routes/api/auth/callback/get/index.ts @@ -0,0 +1,65 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { AuthenticationError } from "#server/utils/authentication-error" +import { readCookie } from "#server/utils/read-cookie" + +export interface AuthenticationCallbackOptions { + maximumCodeLength: number +} + +function isSingleString(value: unknown): value is string { + return typeof value === "string" +} + +function failureUrl(origin: string, reason: "invalid_callback" | "provider_error"): string { + const url = new URL(origin) + url.searchParams.set("authentication", "failed") + url.searchParams.set("reason", reason) + return url.href +} + +export async function get( + options: AuthenticationCallbackOptions, + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + response.clearCookie(cookies.authorization, cookies.privateOptions) + if (request.query.error !== undefined) { + response.redirect(303, failureUrl(authentication.settings.origin, "provider_error")) + return + } + const { code, state } = request.query + if ( + !isSingleString(code) || + code.length === 0 || + code.length > options.maximumCodeLength || + !isSingleString(state) || + !/^[A-Za-z0-9_-]{43}$/.test(state) + ) { + response.redirect(303, failureUrl(authentication.settings.origin, "invalid_callback")) + return + } + try { + const result = await authentication.service.complete( + code, + state, + readCookie(request.headers.cookie, cookies.authorization), + ) + if (result.sessionToken) { + response.cookie(cookies.session, result.sessionToken, cookies.privateOptions) + } + response.cookie(cookies.csrf, randomBytes(32).toString("base64url"), cookies.csrfOptions) + response.redirect(303, `${authentication.settings.origin}/?authentication=complete`) + } catch (error) { + if (error instanceof AuthenticationError) { + response.redirect(303, `${authentication.settings.origin}/?authentication=failed`) + return + } + throw error + } +} diff --git a/hub/server/routes/api/auth/github/callback/get/index.ts b/hub/server/routes/api/auth/github/callback/get/index.ts index 6e65d07c..7d844802 100644 --- a/hub/server/routes/api/auth/github/callback/get/index.ts +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -1,49 +1,19 @@ -import { randomBytes } from "node:crypto" import type { Request, Response } from "express" +import { get as handleAuthenticationCallback } from "#server/routes/api/auth/callback/get" import type { AuthenticationRoutes } from "#server/routes/types" -import { authenticationCookies } from "#server/utils/authentication-cookies" -import { AuthenticationError } from "#server/utils/authentication-error" -import { readCookie } from "#server/utils/read-cookie" -function validCallbackValue(value: unknown): value is string { - return typeof value === "string" && value.length > 0 && value.length <= 1_000 -} +export const maximumGitHubAuthorizationCodeLength = 1_000 export async function get( authentication: AuthenticationRoutes, request: Request, response: Response, ): Promise { - const cookies = authenticationCookies(authentication.settings.secureCookies) - const failureUrl = `${authentication.settings.origin}/?authentication=failed` - response.clearCookie(cookies.authorization, cookies.privateOptions) - const { code, state } = request.query - if ( - request.query.error !== undefined || - !validCallbackValue(code) || - !validCallbackValue(state) || - !/^[\w-]{43}$/.test(state) - ) { - response.redirect(303, failureUrl) - return - } - try { - const result = await authentication.service.complete( - code, - state, - readCookie(request.headers.cookie, cookies.authorization), - ) - if (result.sessionToken) { - response.cookie(cookies.session, result.sessionToken, cookies.privateOptions) - } - response.cookie(cookies.csrf, randomBytes(32).toString("base64url"), cookies.csrfOptions) - response.redirect(303, `${authentication.settings.origin}/?authentication=complete`) - } catch (error) { - if (error instanceof AuthenticationError) { - response.redirect(303, failureUrl) - return - } - throw error - } + await handleAuthenticationCallback( + { maximumCodeLength: maximumGitHubAuthorizationCodeLength }, + authentication, + request, + response, + ) } diff --git a/hub/server/routes/api/auth/oidc/callback/get/index.ts b/hub/server/routes/api/auth/oidc/callback/get/index.ts index 37e6a289..522592e2 100644 --- a/hub/server/routes/api/auth/oidc/callback/get/index.ts +++ b/hub/server/routes/api/auth/oidc/callback/get/index.ts @@ -1 +1,19 @@ -export { get } from "#server/routes/api/auth/github/callback/get" +import type { Request, Response } from "express" + +import { get as handleAuthenticationCallback } from "#server/routes/api/auth/callback/get" +import type { AuthenticationRoutes } from "#server/routes/types" + +export const maximumOidcAuthorizationCodeLength = 8_192 + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await handleAuthenticationCallback( + { maximumCodeLength: maximumOidcAuthorizationCodeLength }, + authentication, + request, + response, + ) +} diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 103daff0..5939af3b 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -87,6 +87,10 @@ const csrfHeaders = { "x-csrf-token": csrfToken, } +function opaqueOidcCode(length: number): string { + return `oidc.${"Ab9_-".repeat(Math.ceil(length / 5))}`.slice(0, length) +} + describe("authentication routes", () => { it("sets host-bound secure cookies and omits repository scope during GitHub initiation", async () => { const runtime = authentication({ @@ -172,6 +176,217 @@ describe("authentication routes", () => { ) }) + it("completes an OIDC callback with a realistic long opaque code without exposing it", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const code = opaqueOidcCode(2_048) + const state = "S_-09azAZ".repeat(5).slice(0, 43) + const runtime = authentication( + { + complete: vi.fn().mockResolvedValue({ + purpose: "sign-in", + sessionToken, + }), + }, + true, + 0, + "oidc", + ) + const baseUrl = await serve(runtime, logger) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ code, state })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.status).toBe(303) + expect(response.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(response.headers.get("set-cookie")).toContain(`__Host-replica-session=${sessionToken}`) + expect(runtime.service.complete).toHaveBeenCalledWith(code, state, browserToken) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify([...response.headers])).not.toContain(code) + expect(JSON.stringify(records)).not.toContain(code) + }) + + it("keeps GitHub authorization codes at the 1000-character boundary", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + const state = "s".repeat(43) + const acceptedCode = "g".repeat(1_000) + const rejectedCode = "g".repeat(1_001) + + const accepted = await fetch( + `${baseUrl}/api/auth/github/callback?${new URLSearchParams({ + code: acceptedCode, + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + const rejected = await fetch( + `${baseUrl}/api/auth/github/callback?${new URLSearchParams({ + code: rejectedCode, + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(accepted.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(rejected.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=invalid_callback", + ) + expect(runtime.service.complete).toHaveBeenCalledOnce() + expect(runtime.service.complete).toHaveBeenCalledWith(acceptedCode, state, browserToken) + }) + + it("accepts an 8192-character OIDC code and rejects the next character", async () => { + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime) + const state = "s".repeat(43) + const acceptedCode = opaqueOidcCode(8_192) + const rejectedCode = opaqueOidcCode(8_193) + + const accepted = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + code: acceptedCode, + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + const rejected = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + code: rejectedCode, + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(accepted.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(rejected.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=invalid_callback", + ) + expect(runtime.service.complete).toHaveBeenCalledOnce() + expect(runtime.service.complete).toHaveBeenCalledWith(acceptedCode, state, browserToken) + }) + + it("accepts exactly one 43-character base64url state value", async () => { + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime) + const state = "Az09_-".repeat(8).slice(0, 43) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + code: "provider-code", + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(runtime.service.complete).toHaveBeenCalledWith("provider-code", state, browserToken) + }) + + it.each([ + ["42 characters", "s".repeat(42)], + ["44 characters", "s".repeat(44)], + ["a non-base64url character", `${"s".repeat(42)}+`], + ])("rejects state with %s before completion", async (_name, state) => { + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + code: "provider-code", + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=invalid_callback", + ) + expect(runtime.service.complete).not.toHaveBeenCalled() + }) + + it.each([ + ["duplicate code", `code=first&code=second&state=${"s".repeat(43)}`], + ["duplicate state", `code=provider-code&state=${"s".repeat(43)}&state=${"t".repeat(43)}`], + ["missing code", `state=${"s".repeat(43)}`], + ["missing state", "code=provider-code"], + ["empty code", `code=&state=${"s".repeat(43)}`], + ["empty state", "code=provider-code&state="], + ])("rejects a callback with %s before completion", async (_name, query) => { + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/auth/oidc/callback?${query}`, { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }) + + expect(response.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=invalid_callback", + ) + expect(runtime.service.complete).not.toHaveBeenCalled() + }) + + it("uses only a safe provider error reason and omits provider callback values from logs", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime, logger) + const errorDescription = "private provider explanation" + const code = opaqueOidcCode(1_024) + const state = "s".repeat(43) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + error: "access_denied", + error_description: errorDescription, + code, + state, + })}`, + { redirect: "manual" }, + ) + + expect(response.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=provider_error", + ) + expect(runtime.service.complete).not.toHaveBeenCalled() + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify(records)).not.toMatch( + new RegExp(`${errorDescription}|${code}|${state}|access_denied`), + ) + }) + it("rejects state-changing requests unless both the origin and CSRF token match", async () => { const runtime = authentication() const baseUrl = await serve(runtime) From 890393f8b5935e7c44838a3e77a7ea00a449bf30 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 11:56:00 -0700 Subject: [PATCH 42/56] docs(hub): note Entra setup pilot status Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/script/setup-entra-oidc.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hub/script/setup-entra-oidc.ps1 b/hub/script/setup-entra-oidc.ps1 index 0271c63f..3e6f6ac3 100644 --- a/hub/script/setup-entra-oidc.ps1 +++ b/hub/script/setup-entra-oidc.ps1 @@ -1,3 +1,5 @@ +# This setup script is still being tested. Review the Plan output and intended tenant +# changes before running Apply. [CmdletBinding()] param( [Parameter(Mandatory = $true)] From 0c358876bfa39ae3f4c5631bafa5e5b6d1e4b4cf Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 12:08:49 -0700 Subject: [PATCH 43/56] test(hub): align OIDC fixtures with identity store Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/server/domains/authentication/create-service/oidc.test.ts | 2 +- .../authentication/ensure-oidc-provider-revision/index.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index f647df81..78ab664b 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -114,7 +114,7 @@ function store(testRecords: TestRecords): IdentityStore { const typedRecords = testRecords as unknown as IdentityRecords return { read: typedRecords, - lock: (operation) => operation(typedRecords), + lock: (operation) => operation(typedRecords, {} as never), } } diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts index 1ef05645..a8ecbfd1 100644 --- a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts @@ -58,7 +58,7 @@ function store(latest: ReturnType | null) { } as unknown as IdentityRecords const identityStore: IdentityStore = { read: records, - lock: (operation) => operation(records), + lock: (operation) => operation(records, {} as never), } return { records: records as IdentityRecords & { From 7bbd3ac5407e54e151b70e42831eca932cac6cdf Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 13:57:20 -0700 Subject: [PATCH 44/56] fix(hub): address OIDC review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/entra-oidc.md | 4 +- hub/docs/identity.md | 6 +- hub/docs/membership-and-administration.md | 8 +- hub/schema/external-identity.prisma | 2 + .../migration.sql | 4 + hub/script/setup-entra-oidc.ps1 | 28 ++- hub/script/tests/setup-entra-oidc.test.mjs | 38 +++- .../create-service/index.db.test.ts | 45 +++++ .../administration/create-service/index.ts | 43 +++- .../complete-oidc-authorization/index.ts | 55 ++++- .../authentication/create-runtime/index.ts | 2 + .../create-service/oidc.test.ts | 189 +++++++++++++++++- .../index.test.ts | 23 +++ .../ensure-provider-admission-policy/index.ts | 9 + .../start-oidc-authorization/index.ts | 22 +- .../domains/identity/create-records/index.ts | 5 + .../identity/create-store/index.db.test.ts | 65 ++++++ .../domains/identity/create-store/types.ts | 5 + .../oidc/create-client/index.test.ts | 15 ++ .../integrations/oidc/create-client/index.ts | 12 +- .../integrations/oidc/create-client/types.ts | 1 + .../index.ts | 15 ++ .../save-external-identity/index.ts | 1 + .../repositories/external-identities/types.ts | 2 + .../installation-memberships/types.ts | 8 +- hub/server/routes/authentication.test.ts | 37 ++++ hub/server/routes/index.ts | 32 +-- 27 files changed, 623 insertions(+), 53 deletions(-) create mode 100644 hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql create mode 100644 hub/server/domains/authentication/ensure-provider-admission-policy/index.test.ts create mode 100644 hub/server/domains/authentication/ensure-provider-admission-policy/index.ts create mode 100644 hub/server/repositories/external-identities/get-external-identity-by-directory-object-id/index.ts diff --git a/hub/docs/entra-oidc.md b/hub/docs/entra-oidc.md index 84d4a02f..e87523e5 100644 --- a/hub/docs/entra-oidc.md +++ b/hub/docs/entra-oidc.md @@ -47,11 +47,11 @@ HUB_OIDC_PROVIDER_REVISION=1 The callback must exactly match `HUB_ORIGIN` plus `/api/auth/oidc/callback`. The first supported profile requires the issuer `https://login.microsoftonline.com//v2.0`; Hub does not accept a generic hostname or tenant alias. Discovered authorization, token, and JWKS endpoints must use that same HTTPS origin. Increase `HUB_OIDC_PROVIDER_REVISION` whenever any OIDC setting or referenced credential changes. Hub rejects revision rollback and same-revision configuration drift. -`jit-assignment` creates a membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. `invitation-required` also requires a matching Hub invitation. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. An administrator claim may promote an active member. Hub refuses a claim-driven demotion that would remove the final active administrator. +`jit-assignment` creates an `oidc-assignment` membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. `invitation-required` also requires a matching Hub invitation. For Entra invitations, enter the user's tenant directory object ID (`oid`), which an administrator can copy from the user's **Object ID** in the Entra admin center or read through approved Microsoft Graph directory tooling. Hub keeps the application-specific OIDC `sub` as the external sign-in identity and uses the tenant-scoped `oid` only to match the pre-created invitation. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. An administrator claim may promote an active member. Hub refuses a claim-driven demotion that would remove the final active administrator. ## Identity and lifecycle boundaries -OIDC proves the immutable tuple `provider + issuer/tenant + subject`. Preferred username, email, name, and picture are mutable display attributes and are never invitation, admission, or authorization keys. Hub discards the authorization code, ID token, access token, and any refresh token after extracting and validating identity and role claims. +OIDC proves the immutable tuple `provider + issuer/tenant + subject`. Preferred username, email, name, and picture are mutable display attributes and are never identity or authorization keys. The signed Entra `oid` claim is a separate tenant-scoped invitation assignment key. Hub discards the authorization code, ID token, access token, and any refresh token after extracting and validating identity, directory assignment, and role claims. JIT provisioning occurs only during a successful sign-in and creates or refreshes Hub membership under the configured admission policy. It is not SCIM: Hub does not synchronize users, groups, suspension, or removal in the background. Entra assignment and app roles control installation admission and administrator mapping; they do not grant future prototype, repository, workspace, artifact, or review permissions. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 9f5bc8c8..88194d20 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -8,7 +8,7 @@ Hub supports standards-based OIDC authorization code flow as a sibling of GitHub OIDC discovery must return the exact configured issuer. Hub binds state to the initiating browser, uses PKCE and nonce, consumes attempts once, exchanges the code with a bounded timeout, verifies the ID-token signature through cached rotating JWKS, and validates issuer, audience, authorized party, token lifetime, and nonce. The configured issuer is host configuration, never request input. Hub rejects non-public or cross-origin discovery endpoints. OIDC callbacks accept one query-string authorization code up to 8,192 characters and one exact 43-character base64url state. GitHub keeps its 1,000-character code limit. Duplicate, missing, or oversized values fail before exchange, with only a stable nonsecret failure reason returned to the browser. Hub requests `response_mode=query`; `form_post` is not enabled. -Hub maps `oidc + issuer/tenant + subject` to `ExternalIdentity`. Login, email, name, and picture are mutable display attributes. They never admit a user or select an existing identity. The authorization response and provider tokens are discarded after identity and app-role extraction. +Hub maps `oidc + issuer/tenant + subject` to `ExternalIdentity`. For Entra, Hub also stores the signed tenant directory object ID (`oid`) as a separate assignment identifier so an administrator can create an invitation before the user's first sign-in. Login, email, name, and picture are mutable display attributes. They never admit a user or select an existing identity. The authorization response and provider tokens are discarded after identity and app-role extraction. ## GitHub user authentication @@ -35,7 +35,7 @@ Sign-out revokes the current database session. Provider logout is optional and i - `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 `member` or `administrator` role and `active`, `suspended`, or terminal `removed` status for this Hub installation. -- `MembershipInvitation` targets an `ExternalIdentity` assignment by provider, issuer, and immutable subject. Activation consumes the invitation once and creates or reactivates the linked user's membership. +- `MembershipInvitation` targets a provider assignment by provider, issuer, and immutable assignment identifier. GitHub uses its account subject; Entra uses the tenant directory object ID (`oid`) so administrators can create an invitation before first sign-in. Activation consumes the invitation once and creates or reactivates the linked user's membership. - `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. @@ -47,7 +47,7 @@ Store operations can share one database transaction and lock a fixed store-state Set separate host-held `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values. Bootstrap can enroll the first administrator once. Its durable completion marker remains after expired authorization attempts are cleaned up. Recovery is available only when no active administrator remains and never resets bootstrap. -Administrators invite an identity by provider, issuer, and immutable subject. Mutable usernames, display names, and email addresses do not activate invitations. Invitations expire after seven days, can be revoked before use, and are consumed by one matching authentication callback. Removed memberships require a new invitation. Suspended memberships must be restored by an administrator. +Administrators invite an identity by provider, issuer, and immutable assignment identifier. Mutable usernames, display names, and email addresses do not activate invitations. Invitations expire after seven days, can be revoked before use, and are consumed by one matching authentication callback. Removed memberships require a new invitation. Suspended memberships must be restored by an administrator. Administrator mutations require an active administrator session verified less than five minutes ago. Hub prevents concurrent demotion, suspension, or removal from eliminating the final active administrator. See [Installation membership and administration](membership-and-administration.md). diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index 1925f139..f70a3f8e 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -2,7 +2,7 @@ Hub owns installation admission after an authentication provider proves an identity. Repository access affects installation membership only through the explicit GitHub-synchronized policy; Hub remains the final evaluator. -For OIDC, a configured member or administrator app role is required before Hub evaluates membership. Under `jit-assignment`, a first successful sign-in may create membership from that assignment. Under `invitation-required`, the immutable provider, issuer, and subject must also have a usable Hub invitation. Suspended membership remains suspended. Removed membership requires a new invitation and is never silently reactivated from a provider claim. +For OIDC, a configured member or administrator app role is required before Hub evaluates membership. Under `jit-assignment`, a first successful sign-in may create an `oidc-assignment` membership from that assignment. Under `invitation-required`, an Entra administrator creates the invitation with the tenant issuer and the user's directory Object ID (`oid`) from the Entra admin center or approved Graph tooling. Hub matches that signed tenant-scoped claim while retaining the application-specific OIDC `sub` as the external identity. Suspended membership remains suspended. Removed membership requires a new invitation and is never silently reactivated from a provider claim. ## Membership contract @@ -23,13 +23,13 @@ Suspension and removal also check Hub-managed product administration in the same ## Invitations -An invitation targets an external identity assignment: +An invitation targets an external provider assignment: ```text -provider + issuer + immutable subject -> ExternalIdentity -> User -> InstallationMembership +provider + issuer + immutable assignment identifier -> ExternalIdentity -> User -> InstallationMembership ``` -An administrator may invite a member or administrator. Hub never matches mutable login, display-name, or email fields. The invitation expires after seven days, can be revoked before activation, and can be consumed once. A matching authentication callback activates the membership and creates the session in one serialized operation. +An administrator may invite a member or administrator. GitHub invitations use the immutable account subject. Entra invitations use the tenant directory Object ID (`oid`) so the value is available before the application-specific OIDC `sub` is first observed. Hub never matches mutable login, display-name, or email fields. The invitation expires after seven days, can be revoked before activation, and can be consumed once. A matching authentication callback activates the membership and creates the session in one serialized operation. Hub rejects invitations for active or suspended assignments. A removed assignment may receive a new invitation. diff --git a/hub/schema/external-identity.prisma b/hub/schema/external-identity.prisma index c1ac6ffb..6e5b4fdc 100644 --- a/hub/schema/external-identity.prisma +++ b/hub/schema/external-identity.prisma @@ -12,6 +12,7 @@ model ExternalIdentity { provider String issuer String subject String + directoryObjectId String? @map("directory_object_id") username String? email String? displayName String? @map("display_name") @@ -22,6 +23,7 @@ model ExternalIdentity { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) @@unique([provider, issuer, subject]) + @@unique([provider, issuer, directoryObjectId]) @@index([userId]) @@map("external_identities") } diff --git a/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql b/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql new file mode 100644 index 00000000..8eadefa6 --- /dev/null +++ b/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "external_identities" ADD COLUMN "directory_object_id" TEXT; + +CREATE UNIQUE INDEX "external_identities_provider_issuer_directory_object_id_key" +ON "external_identities"("provider", "issuer", "directory_object_id"); diff --git a/hub/script/setup-entra-oidc.ps1 b/hub/script/setup-entra-oidc.ps1 index 3e6f6ac3..3e629588 100644 --- a/hub/script/setup-entra-oidc.ps1 +++ b/hub/script/setup-entra-oidc.ps1 @@ -80,7 +80,6 @@ function Get-AppRoles { Description = "Use Replica Hub" DisplayName = "Hub User" IsEnabled = $true - Origin = "Application" Value = "Hub.User" }, @{ @@ -89,7 +88,6 @@ function Get-AppRoles { Description = "Administer Replica Hub" DisplayName = "Hub Administrator" IsEnabled = $true - Origin = "Application" Value = "Hub.Administrator" } ) @@ -216,6 +214,15 @@ function Get-ConvergedRoles { Assert-ManagedRoleIdsAvailable -Application $Application $otherRoles = @($Application.AppRoles).Where({ $_.Value -notin @("Hub.User", "Hub.Administrator") + }).ForEach({ + @{ + Id = $_.Id + AllowedMemberTypes = @($_.AllowedMemberTypes) + Description = $_.Description + DisplayName = $_.DisplayName + IsEnabled = $_.IsEnabled + Value = $_.Value + } }) return @($otherRoles) + @(Get-AppRoles) } @@ -228,9 +235,12 @@ function Test-ApplicationDrift { ) if ($Application.SignInAudience -ne "AzureADMyOrg") { return $true } - $actualRedirectUris = @($Application.Web.RedirectUris) | Sort-Object - $expectedRedirectUris = @($DesiredRedirectUris) | Sort-Object - if (Compare-Object $actualRedirectUris $expectedRedirectUris) { return $true } + $actualRedirectUris = @(@($Application.Web.RedirectUris) | Sort-Object -CaseSensitive) + $expectedRedirectUris = @(@($DesiredRedirectUris) | Sort-Object -CaseSensitive) + if ($actualRedirectUris.Count -ne $expectedRedirectUris.Count) { return $true } + for ($index = 0; $index -lt $actualRedirectUris.Count; $index += 1) { + if ($actualRedirectUris[$index] -cne $expectedRedirectUris[$index]) { return $true } + } foreach ($desired in Get-AppRoles) { $actual = @($Application.AppRoles).Where({ $_.Value -eq $desired.Value }) @@ -382,7 +392,13 @@ function Invoke-Plan { Get-AssignmentChanges -ServicePrincipal $servicePrincipal ` -UserGroups $UserGroupId -AdministratorGroups $AdministratorGroupId } - else { [pscustomobject] @{ Missing = @(); Obsolete = @() } } + else { + [pscustomobject] @{ + Missing = @(Get-DesiredAssignments -UserGroups $UserGroupId ` + -AdministratorGroups $AdministratorGroupId) + Obsolete = @() + } + } foreach ($assignment in $changes.Missing) { $actions += "Assign group '$($assignment.GroupId)' to $($assignment.Role)." } diff --git a/hub/script/tests/setup-entra-oidc.test.mjs b/hub/script/tests/setup-entra-oidc.test.mjs index 602ddc7d..b5e7ced5 100644 --- a/hub/script/tests/setup-entra-oidc.test.mjs +++ b/hub/script/tests/setup-entra-oidc.test.mjs @@ -62,7 +62,9 @@ describe("setup-entra-oidc.ps1", () => { /foreach \(\$assignment in \$changes\.Missing\)[\s\S]*New-MgGroupAppRoleAssignment/, ) assert.match(drift, /AzureADMyOrg/) - assert.match(drift, /Compare-Object \$actualRedirectUris \$expectedRedirectUris/) + assert.match(drift, /Sort-Object -CaseSensitive/) + assert.match(drift, /\$actualRedirectUris\.Count -ne \$expectedRedirectUris\.Count/) + assert.match(drift, /\$actualRedirectUris\[\$index\] -cne \$expectedRedirectUris\[\$index\]/) assert.match(apply, /Assert-GroupsExist[\s\S]*Resolve-Application/) assert.match( apply, @@ -70,6 +72,30 @@ describe("setup-entra-oidc.ps1", () => { ) }) + it("keeps app roles writable and projects preserved roles to writable fields", () => { + const desiredRoles = functionBody("Get-AppRoles") + const convergedRoles = functionBody("Get-ConvergedRoles") + + assert.doesNotMatch(desiredRoles, /\bOrigin\b/) + assert.match(convergedRoles, /Id = \$_.Id/) + assert.match(convergedRoles, /AllowedMemberTypes = @\(\$_.AllowedMemberTypes\)/) + assert.match(convergedRoles, /Description = \$_.Description/) + assert.match(convergedRoles, /DisplayName = \$_.DisplayName/) + assert.match(convergedRoles, /IsEnabled = \$_.IsEnabled/) + assert.match(convergedRoles, /Value = \$_.Value/) + assert.doesNotMatch(convergedRoles, /\bOrigin\s*=/) + }) + + it("treats empty and case-changed redirect URI lists as drift", () => { + const drift = functionBody("Test-ApplicationDrift") + + assert.match(drift, /@\(@\(\$Application\.Web\.RedirectUris\) \| Sort-Object -CaseSensitive\)/) + assert.match(drift, /@\(@\(\$DesiredRedirectUris\) \| Sort-Object -CaseSensitive\)/) + assert.match(drift, /\$actualRedirectUris\.Count -ne \$expectedRedirectUris\.Count/) + assert.match(drift, /-cne/) + assert.doesNotMatch(drift, /\bCompare-Object\b/) + }) + it("limits obsolete assignments to managed group roles and preserves unrelated assignments", () => { const assignments = functionBody("Get-AssignmentChanges") @@ -95,6 +121,16 @@ describe("setup-entra-oidc.ps1", () => { assert.match(plan, /else \{ "Hub\.Administrator" \}/) }) + it("reports every desired assignment when the application exists without a service principal", () => { + const plan = functionBody("Invoke-Plan") + + assert.match( + plan, + /else \{\s*\[pscustomobject\] @\{\s*Missing = @\(Get-DesiredAssignments -UserGroups \$UserGroupId/, + ) + assert.match(plan, /-AdministratorGroups \$AdministratorGroupId\)\s*Obsolete = @\(\)/) + }) + it("removes obsolete managed group assignments during apply", () => { const apply = functionBody("Invoke-Apply") diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index d26d3292..2f7595b1 100644 --- a/hub/server/domains/administration/create-service/index.db.test.ts +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -68,6 +68,51 @@ async function createAdministrator( } describe("createAdministrationService() database behavior", () => { + it("detects an existing OIDC assignment by its normalized directory object ID", async () => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const directoryObjectId = "a0e1d2c3-b4a5-4678-9abc-def012345678" + await store.lock(async (records) => { + const user = await records.createUser({ + displayName: "OIDC member", + avatarUrl: null, + }) + await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: "https://login.example.test/tenant", + subject: "provider-subject", + directoryObjectId, + username: "member@example.test", + displayName: "OIDC member", + avatarUrl: null, + }) + await records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source: "oidc-assignment", + }) + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.invite("actor-token", { + provider: "oidc", + issuer: "https://login.example.test/tenant", + subject: directoryObjectId.toUpperCase(), + role: "member", + }), + ).rejects.toMatchObject({ + status: 409, + code: "identity_already_assigned", + }) + expect(await store.read.invitations()).toEqual([]) + }) + it("serializes overlapping suspensions so one active administrator and its session remain", async () => { const { store } = await database() const first = await createAdministrator(store, "41", "first-token") diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index 4d642452..d747845b 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -8,12 +8,26 @@ import { getCurrentSession } from "#server/domains/authentication/get-current-se import { ensureProductAdministrationContinuity } from "#server/domains/prototype-authorization/ensure-administration-continuity" import { AuthenticationError } from "#server/utils/authentication-error" -const invitationSchema = z.object({ - provider: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/), - issuer: z.url().max(500), - subject: z.string().trim().min(1).max(500), - role: z.enum(["member", "administrator"]), -}) +const invitationSchema = z + .object({ + provider: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/), + issuer: z.url().max(500), + subject: z.string().trim().min(1).max(500), + role: z.enum(["member", "administrator"]), + }) + .superRefine(({ provider, subject }, context) => { + if (provider === "oidc" && !z.uuid().safeParse(subject).success) { + context.addIssue({ + code: "custom", + path: ["subject"], + message: "OIDC invitations require a directory object ID.", + }) + } + }) + .transform((input) => ({ + ...input, + subject: input.provider === "oidc" ? input.subject.toLowerCase() : input.subject, + })) const membershipChangeSchema = z .object({ @@ -101,11 +115,18 @@ export function createAdministrationService( ) } await records.revokeExpiredInvitations(dependencies.now()) - const identity = await records.externalIdentity( - parsed.data.provider, - parsed.data.issuer, - parsed.data.subject, - ) + const identity = + parsed.data.provider === "oidc" + ? await records.externalIdentityByDirectoryObjectId( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject.toLowerCase(), + ) + : await records.externalIdentity( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + ) const membership = identity ? await records.membership(identity.userId) : null if (membership && membership.status !== "removed") { throw new AuthenticationError( diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts index 8a7f0059..489d539a 100644 --- a/hub/server/domains/authentication/complete-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -85,6 +85,7 @@ export async function completeOidcAuthorization( const session = attempt.originatingSessionId ? await records.sessionById(attempt.originatingSessionId) : null + const membership = session ? await records.membership(session.userId) : null if ( !session || session.externalIdentity.provider !== "oidc" || @@ -93,6 +94,22 @@ export async function completeOidcAuthorization( ) { throw new AuthenticationError(403, "identity_mismatch", "Verify the same OIDC identity.") } + if (!profile.role || membership?.status !== "active" || membership.role !== profile.role) { + await records.audit({ + event: "session.verify", + outcome: "denied", + actorUserId: session.userId, + sessionId: session.id, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: "provider_role_changed" }, + }) + throw new AuthenticationError( + 403, + "provider_role_changed", + "Your provider role changed. Sign in again.", + ) + } await records.verifySession(session.id, now()) await records.audit({ event: "session.verify", @@ -117,9 +134,13 @@ export async function completeOidcAuthorization( } let identity = await records.externalIdentity("oidc", oidcConfiguration.issuer, profile.subject) let membership = identity ? await records.membership(identity.userId) : null + const invitationSubject = profile.directoryObjectId const invitation = - purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" - ? await records.usableInvitation("oidc", oidcConfiguration.issuer, profile.subject, now()) + purpose === "sign-in" && + invitationSubject && + membership?.status !== "active" && + membership?.status !== "suspended" + ? await records.usableInvitation("oidc", oidcConfiguration.issuer, invitationSubject, now()) : null if ( purpose === "sign-in" && @@ -136,15 +157,37 @@ export async function completeOidcAuthorization( sessionId: null, authenticationProviderRevisionId: oidcProviderRevisionId, targetMembershipId: membership?.id ?? null, - context: { reason: membership?.status ?? "invitation_required" }, + context: { + reason: + membership?.status ?? + (invitationSubject ? "invitation_required" : "directory_object_id_missing"), + }, }) return { denied: true as const } } if (purpose === "bootstrap") { const completed = (await records.activeAdministratorCount()) === 0 && (await records.completeBootstrap(now())) - if (!completed) return { denied: true as const } + if (!completed) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason: "unavailable" }, + }) + return { denied: true as const } + } } else if (purpose === "recovery" && (await records.activeAdministratorCount()) > 0) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason: "active_administrator_exists" }, + }) return { denied: true as const } } if (!identity) { @@ -157,6 +200,7 @@ export async function completeOidcAuthorization( provider: "oidc", issuer: oidcConfiguration.issuer, subject: profile.subject, + directoryObjectId: profile.directoryObjectId, username: profile.username, email: profile.email, displayName: profile.displayName, @@ -172,6 +216,7 @@ export async function completeOidcAuthorization( provider: "oidc", issuer: oidcConfiguration.issuer, subject: profile.subject, + directoryObjectId: profile.directoryObjectId, username: profile.username, email: profile.email, displayName: profile.displayName, @@ -236,7 +281,7 @@ export async function completeOidcAuthorization( userId: identity.userId, status: "active", role: profile.role, - source: "synchronized", + source: "oidc-assignment", suspendedAt: null, removedAt: null, }) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 4993862a..973075e3 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -6,6 +6,7 @@ import { createArtifactPublishingService } from "#server/domains/artifact-publis import { createAdministrationService } from "#server/domains/administration/create-service" import { createAuthenticationService } from "#server/domains/authentication/create-service" import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" +import { ensureProviderAdmissionPolicy } from "#server/domains/authentication/ensure-provider-admission-policy" import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" import { ensureOidcProviderRevision } from "#server/domains/authentication/ensure-oidc-provider-revision" import { readAuthenticationProvider } from "#server/domains/authentication/read-authentication-provider" @@ -89,6 +90,7 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv const oidcProviderRevision = oidcConfiguration ? await ensureOidcProviderRevision(store, oidcConfiguration) : null + ensureProviderAdmissionPolicy(provider, environment) const admissionPolicy = createAdmissionPolicy(environment, store, githubAuthorization) const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index 78ab664b..f090b865 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -51,6 +51,7 @@ function records(overrides: Partial = {}) { createAuthorizationAttempt: vi.fn().mockResolvedValue(undefined), consumeAuthorizationAttempt: vi.fn().mockResolvedValue(null), externalIdentity: vi.fn().mockResolvedValue(null), + externalIdentityByDirectoryObjectId: vi.fn().mockResolvedValue(null), createUser: vi.fn().mockResolvedValue({ id: "user", displayName: "Member Name", @@ -121,6 +122,7 @@ function store(testRecords: TestRecords): IdentityStore { function profile(overrides: Partial = {}): OidcProfile { return { subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", username: "member@example.test", email: "member@example.test", displayName: "Member Name", @@ -191,7 +193,7 @@ function activeMembership(role: "member" | "administrator" = "member") { userId: "user", status: "active" as const, role, - source: "synchronized" as const, + source: "oidc-assignment" as const, suspendedAt: null, removedAt: null, } @@ -204,6 +206,7 @@ function existingIdentity() { provider: "oidc", issuer, subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", username: "old-name@example.test", email: "old-email@example.test", displayName: "Old Name", @@ -241,6 +244,29 @@ describe("createAuthenticationService() OIDC", () => { }) }) + it("consumes the saved attempt and audits a provider failure when initiation discovery fails", async () => { + const identityRecords = records() + const client = oidc({ + authorizationUrl: vi.fn().mockRejectedValue(new Error("discovery unavailable")), + }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.start("sign-in")).rejects.toMatchObject({ + status: 503, + code: "authentication_provider_failed", + }) + + const [{ stateDigest }] = identityRecords.createAuthorizationAttempt.mock.calls[0]! + expect(identityRecords.consumeAuthorizationAttempt).toHaveBeenCalledWith(stateDigest) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "authentication.provider_failed", + outcome: "failed", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + }) + }) + it("consumes the attempt before rejecting the wrong browser, preventing a valid replay", async () => { let available: ReturnType | null = attempt() const identityRecords = records({ @@ -376,7 +402,7 @@ describe("createAuthenticationService() OIDC", () => { }) it.each(["member", "administrator"] as const)( - "maps a new %s claim to an active synchronized membership", + "maps a new %s claim to an active OIDC-assignment membership", async (role) => { const identityRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), @@ -392,7 +418,7 @@ describe("createAuthenticationService() OIDC", () => { userId: "user", status: "active", role, - source: "synchronized", + source: "oidc-assignment", suspendedAt: null, removedAt: null, }) @@ -429,6 +455,7 @@ describe("createAuthenticationService() OIDC", () => { provider: "oidc", issuer, subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", username: "renamed@example.test", email: "new-email@example.test", displayName: "New Name", @@ -436,6 +463,78 @@ describe("createAuthenticationService() OIDC", () => { }) }) + it("refreshes authorization only when the active membership still matches the signed provider role", async () => { + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "digest", + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "Member Name", avatarUrl: null }, + externalIdentity: existingIdentity(), + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi + .fn() + .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + sessionById: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue(activeMembership("administrator")), + }) + const client = oidc({ identify: vi.fn().mockResolvedValue(profile({ role: "member" })) }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "provider_role_changed", + }) + expect(identityRecords.verifySession).not.toHaveBeenCalled() + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "session.verify", + outcome: "denied", + actorUserId: "user", + sessionId: "session", + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: "membership", + context: { reason: "provider_role_changed" }, + }) + + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + client.identify.mockResolvedValueOnce(profile({ role: null })) + identityRecords.membership.mockResolvedValueOnce(activeMembership("member")) + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "provider_role_changed", + }) + + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + client.identify.mockResolvedValueOnce(profile({ role: "member" })) + identityRecords.membership.mockResolvedValueOnce({ + ...activeMembership("member"), + status: "suspended", + suspendedAt: now, + }) + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "provider_role_changed", + }) + + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + identityRecords.membership.mockResolvedValueOnce(activeMembership("member")) + await expect(authentication.complete("code", "state", "browser-token")).resolves.toEqual({ + purpose: "verify", + }) + expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) + }) + it("refuses a role refresh that would remove the final active administrator", async () => { const identityRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), @@ -500,7 +599,7 @@ describe("createAuthenticationService() OIDC", () => { activatedMembershipId: null, provider: "oidc", issuer, - subject: "immutable-subject", + subject: "a0e1d2c3-b4a5-4678-9abc-def012345678", role: "member" as const, expiresAt: new Date(now.getTime() + 60_000), consumedAt: null, @@ -520,6 +619,12 @@ describe("createAuthenticationService() OIDC", () => { "browser-token", ) expect(invitedResult.sessionToken).toEqual(expect.any(String)) + expect(invitedRecords.usableInvitation).toHaveBeenCalledWith( + "oidc", + issuer, + "a0e1d2c3-b4a5-4678-9abc-def012345678", + now, + ) expect(invitedRecords.saveMembership).toHaveBeenCalledWith({ userId: "user", status: "active", @@ -536,6 +641,80 @@ describe("createAuthenticationService() OIDC", () => { ) }) + it("activates a pre-sign-in invitation by directory object ID while retaining the OIDC subject", async () => { + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "oidc", + issuer, + subject: "a0e1d2c3-b4a5-4678-9abc-def012345678", + role: "member" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation), + }) + + await service(identityRecords, oidc(), { + membershipPolicy: "invitation-required", + }).authentication.complete("code", "state", "browser-token") + + expect(identityRecords.usableInvitation).toHaveBeenCalledWith( + "oidc", + issuer, + "a0e1d2c3-b4a5-4678-9abc-def012345678", + now, + ) + expect(identityRecords.saveExternalIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "oidc", + issuer, + subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + }), + ) + }) + + it.each([ + ["bootstrap", "membership.bootstrap", "unavailable"], + ["recovery", "membership.recovery", "active_administrator_exists"], + ] as const)( + "audits a %s denial when enrollment becomes unavailable after initiation", + async (purpose, event, reason) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt({ purpose })), + completeBootstrap: vi.fn().mockResolvedValue(purpose !== "bootstrap"), + activeAdministratorCount: vi.fn().mockResolvedValue(purpose === "recovery" ? 1 : 0), + }) + const { authentication } = service( + identityRecords, + oidc({ identify: vi.fn().mockResolvedValue(profile({ role: "administrator" })) }), + ) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject( + { + status: 403, + code: "admission_denied", + }, + ) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event, + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason }, + }) + expect(identityRecords.createSession).not.toHaveBeenCalled() + }, + ) + it("keeps invitation-required admission distinct from just-in-time assignment", async () => { const invitationRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), @@ -558,7 +737,7 @@ describe("createAuthenticationService() OIDC", () => { ) expect(jitResult.sessionToken).toEqual(expect.any(String)) expect(jitRecords.saveMembership).toHaveBeenCalledWith( - expect.objectContaining({ source: "synchronized" }), + expect.objectContaining({ source: "oidc-assignment" }), ) }) diff --git a/hub/server/domains/authentication/ensure-provider-admission-policy/index.test.ts b/hub/server/domains/authentication/ensure-provider-admission-policy/index.test.ts new file mode 100644 index 00000000..29bb437a --- /dev/null +++ b/hub/server/domains/authentication/ensure-provider-admission-policy/index.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest" + +import { ensureProviderAdmissionPolicy } from "#server/domains/authentication/ensure-provider-admission-policy" + +describe("ensureProviderAdmissionPolicy()", () => { + it.each(["github-synchronized", "legacy-repository"])( + "rejects OIDC with the non-Hub-managed %s admission policy", + (policy) => { + expect(() => ensureProviderAdmissionPolicy("oidc", { HUB_ADMISSION_POLICY: policy })).toThrow( + "OIDC authentication requires HUB_ADMISSION_POLICY=hub-managed.", + ) + }, + ) + + it("allows Hub-managed OIDC and preserves GitHub admission policy choices", () => { + expect(() => ensureProviderAdmissionPolicy("oidc", {})).not.toThrow() + expect(() => + ensureProviderAdmissionPolicy("github", { + HUB_ADMISSION_POLICY: "github-synchronized", + }), + ).not.toThrow() + }) +}) diff --git a/hub/server/domains/authentication/ensure-provider-admission-policy/index.ts b/hub/server/domains/authentication/ensure-provider-admission-policy/index.ts new file mode 100644 index 00000000..31a7da19 --- /dev/null +++ b/hub/server/domains/authentication/ensure-provider-admission-policy/index.ts @@ -0,0 +1,9 @@ +export function ensureProviderAdmissionPolicy( + provider: "github" | "oidc", + environment: NodeJS.ProcessEnv, +): void { + const policy = environment.HUB_ADMISSION_POLICY ?? "hub-managed" + if (provider === "oidc" && policy !== "hub-managed") { + throw new Error("OIDC authentication requires HUB_ADMISSION_POLICY=hub-managed.") + } +} diff --git a/hub/server/domains/authentication/start-oidc-authorization/index.ts b/hub/server/domains/authentication/start-oidc-authorization/index.ts index 1f34257d..a57c9ef4 100644 --- a/hub/server/domains/authentication/start-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/start-oidc-authorization/index.ts @@ -107,8 +107,28 @@ export async function startOidcAuthorization( return null }) if (denial) throw denial + let authorizationUrl: string + try { + authorizationUrl = await oidc.authorizationUrl(state, pkceVerifier, nonce) + } catch { + await store.lock(async (records) => { + await records.consumeAuthorizationAttempt(digestSecret(state)) + await records.audit({ + event: "authentication.provider_failed", + outcome: "failed", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + }) + }) + throw new AuthenticationError( + 503, + "authentication_provider_failed", + "The authentication provider failed.", + ) + } return { - authorizationUrl: await oidc.authorizationUrl(state, pkceVerifier, nonce), + authorizationUrl, browserToken, } } diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 600a0c62..f0bbe9ba 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -5,6 +5,7 @@ import { getAuthenticationProviderRevision } from "#server/repositories/authenti 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 { getExternalIdentityByDirectoryObjectId } from "#server/repositories/external-identities/get-external-identity-by-directory-object-id" import { saveExternalIdentity } from "#server/repositories/external-identities/save-external-identity" import { advanceGitHubIntegrationGeneration } from "#server/repositories/github-integration-state/advance-generation" import { hasFreshGitHubAdmission } from "#server/repositories/github-synchronized-access/has-fresh-admission" @@ -57,6 +58,10 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident createUser: createUser.bind(null, database), updateUser: updateUser.bind(null, database), externalIdentity: getExternalIdentity.bind(null, database), + externalIdentityByDirectoryObjectId: getExternalIdentityByDirectoryObjectId.bind( + null, + database, + ), saveExternalIdentity: saveExternalIdentity.bind(null, database), membership: getInstallationMembership.bind(null, database), membershipById: getInstallationMembershipById.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..836e8ddc 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -172,6 +172,71 @@ describe("createIdentityStore()", () => { expect(await store.read.user(firstUser.id)).not.toHaveProperty("administrator") }) + it("persists directory object IDs as issuer-scoped unique assignment identifiers", async () => { + const { store } = await database() + const firstUser = await store.lock((records) => + records.createUser({ displayName: "First user", avatarUrl: null }), + ) + const secondUser = await store.lock((records) => + records.createUser({ displayName: "Second user", avatarUrl: null }), + ) + const directoryObjectId = "a0e1d2c3-b4a5-4678-9abc-def012345678" + const firstIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: firstUser.id, + provider: "oidc", + issuer: "https://login.example.test/tenant", + subject: "first-subject", + directoryObjectId, + username: "first@example.test", + displayName: "First user", + avatarUrl: null, + }), + ) + + expect(firstIdentity.directoryObjectId).toBe(directoryObjectId) + await expect( + store.lock((records) => + records.saveExternalIdentity({ + userId: secondUser.id, + provider: "oidc", + issuer: "https://login.example.test/tenant", + subject: "second-subject", + directoryObjectId, + username: "second@example.test", + displayName: "Second user", + avatarUrl: null, + }), + ), + ).rejects.toThrow() + await expect( + store.read.externalIdentityByDirectoryObjectId( + "oidc", + "https://login.example.test/tenant", + directoryObjectId, + ), + ).resolves.toMatchObject({ + id: firstIdentity.id, + subject: "first-subject", + directoryObjectId, + }) + + await expect( + store.lock((records) => + records.saveExternalIdentity({ + userId: secondUser.id, + provider: "oidc", + issuer: "https://login.example.test/other-tenant", + subject: "second-subject", + directoryObjectId, + username: "second@example.test", + displayName: "Second user", + avatarUrl: null, + }), + ), + ).resolves.toMatchObject({ userId: secondUser.id, directoryObjectId }) + }) + it("persists installation status, role, and source independently from the user", async () => { const { store } = await database() const user = await store.lock((records) => diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index f832cd84..6843f674 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -57,6 +57,11 @@ export interface IdentityRecords { issuer: string, subject: string, ): Promise<(HubExternalIdentity & { user: HubUser }) | null> + externalIdentityByDirectoryObjectId( + provider: string, + issuer: string, + directoryObjectId: string, + ): Promise<(HubExternalIdentity & { user: HubUser }) | null> saveExternalIdentity( input: ExternalIdentityInput, ): Promise diff --git a/hub/server/integrations/oidc/create-client/index.test.ts b/hub/server/integrations/oidc/create-client/index.test.ts index 272972ae..3f635a4b 100644 --- a/hub/server/integrations/oidc/create-client/index.test.ts +++ b/hub/server/integrations/oidc/create-client/index.test.ts @@ -159,6 +159,7 @@ describe("createOidcClient()", () => { const key = await signingKey() const token = await idToken(key, { roles: ["Hub.User", "Hub.Administrator", 42], + oid: "A0E1D2C3-B4A5-4678-9ABC-DEF012345678", }) const request = providerFetch({ token, keys: [key.jwk] }) const client = createOidcClient(configuration, { fetch: request }) @@ -167,6 +168,7 @@ describe("createOidcClient()", () => { expect(profile).toEqual({ subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", username: "member@example.test", email: "member@example.test", displayName: "Member Name", @@ -202,6 +204,7 @@ describe("createOidcClient()", () => { ["authorized party", { azp: "different-client" }], ["multi-audience authorized party", { aud: ["hub-client", "other-client"] }], ["issued-at", { iat: Math.floor(Date.now() / 1_000) + 60 }], + ["expiration", { exp: undefined }], [ "lifetime", { @@ -219,6 +222,18 @@ describe("createOidcClient()", () => { await expect(client.identify("code", "verifier", "expected-nonce")).rejects.toThrow() }) + it("rejects a malformed directory object ID instead of treating it as an assignment identifier", async () => { + const key = await signingKey() + const token = await idToken(key, { oid: "not-a-directory-object-id" }) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [key.jwk] }), + }) + + await expect(client.identify("code", "verifier", "expected-nonce")).rejects.toThrow( + "OIDC directory object ID is invalid.", + ) + }) + it("rejects an ID token signed by a key outside the provider JWKS", async () => { const trusted = await signingKey() const untrusted = await signingKey() diff --git a/hub/server/integrations/oidc/create-client/index.ts b/hub/server/integrations/oidc/create-client/index.ts index 1340fb43..c34a1079 100644 --- a/hub/server/integrations/oidc/create-client/index.ts +++ b/hub/server/integrations/oidc/create-client/index.ts @@ -62,6 +62,13 @@ function optionalString(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null } +function optionalUuid(value: unknown): string | null { + if (value === undefined) return null + const parsed = z.uuid().safeParse(value) + if (!parsed.success) throw new Error("OIDC directory object ID is invalid.") + return parsed.data.toLowerCase() +} + export function createOidcClient( configuration: OidcConfiguration, options: OidcClientOptions = {}, @@ -165,8 +172,8 @@ export function createOidcClient( maxTokenAge: "10m", }) const claims = verified.payload - if (!claims.sub || claims.nonce !== nonce) { - throw new Error("OIDC token subject or nonce is invalid.") + if (!claims.sub || typeof claims.exp !== "number" || claims.nonce !== nonce) { + throw new Error("OIDC token subject, expiration, or nonce is invalid.") } if ( (claims.azp !== undefined && claims.azp !== configuration.clientId) || @@ -186,6 +193,7 @@ export function createOidcClient( : null return { subject: claims.sub, + directoryObjectId: optionalUuid(claims.oid), username: optionalString(claims.preferred_username) ?? optionalString(claims.email) ?? diff --git a/hub/server/integrations/oidc/create-client/types.ts b/hub/server/integrations/oidc/create-client/types.ts index 841af9f8..38b38e24 100644 --- a/hub/server/integrations/oidc/create-client/types.ts +++ b/hub/server/integrations/oidc/create-client/types.ts @@ -14,6 +14,7 @@ export interface OidcConfiguration { export interface OidcProfile { subject: string + directoryObjectId?: string | null username: string | null email: string | null displayName: string | null diff --git a/hub/server/repositories/external-identities/get-external-identity-by-directory-object-id/index.ts b/hub/server/repositories/external-identities/get-external-identity-by-directory-object-id/index.ts new file mode 100644 index 00000000..fc9ddca6 --- /dev/null +++ b/hub/server/repositories/external-identities/get-external-identity-by-directory-object-id/index.ts @@ -0,0 +1,15 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getExternalIdentityByDirectoryObjectId( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + directoryObjectId: string, +) { + return database.externalIdentity.findUnique({ + where: { + provider_issuer_directoryObjectId: { provider, issuer, directoryObjectId }, + }, + 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 index 30df0d93..d9f4f29c 100644 --- a/hub/server/repositories/external-identities/save-external-identity/index.ts +++ b/hub/server/repositories/external-identities/save-external-identity/index.ts @@ -11,6 +11,7 @@ export function saveExternalIdentity( where: { provider_issuer_subject: { provider, issuer, subject } }, create: identity, update: { + directoryObjectId: identity.directoryObjectId ?? null, username: identity.username, email: identity.email ?? null, displayName: identity.displayName, diff --git a/hub/server/repositories/external-identities/types.ts b/hub/server/repositories/external-identities/types.ts index e24db9f8..53d7f703 100644 --- a/hub/server/repositories/external-identities/types.ts +++ b/hub/server/repositories/external-identities/types.ts @@ -3,6 +3,7 @@ export interface ExternalIdentityInput { provider: string issuer: string subject: string + directoryObjectId?: string | null username: string | null email?: string | null displayName: string | null @@ -11,5 +12,6 @@ export interface ExternalIdentityInput { export interface HubExternalIdentity extends ExternalIdentityInput { id: string + directoryObjectId: string | null email: string | null } diff --git a/hub/server/repositories/installation-memberships/types.ts b/hub/server/repositories/installation-memberships/types.ts index 60a72238..18269e33 100644 --- a/hub/server/repositories/installation-memberships/types.ts +++ b/hub/server/repositories/installation-memberships/types.ts @@ -3,7 +3,13 @@ export interface InstallationMembershipInput { status: "active" | "suspended" | "removed" role: "member" | "administrator" source: - "bootstrap" | "invitation" | "legacy-synchronized" | "manual" | "recovery" | "synchronized" + | "bootstrap" + | "invitation" + | "legacy-synchronized" + | "manual" + | "oidc-assignment" + | "recovery" + | "synchronized" suspendedAt?: Date | null removedAt?: Date | null } diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 5939af3b..dfddf597 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -92,6 +92,43 @@ function opaqueOidcCode(length: number): string { } describe("authentication routes", () => { + it.each([ + [ + "github", + "/api/auth/github", + "/api/auth/github/callback", + "/api/auth/oidc", + "/api/auth/oidc/callback", + ], + [ + "oidc", + "/api/auth/oidc", + "/api/auth/oidc/callback", + "/api/auth/github", + "/api/auth/github/callback", + ], + ] as const)( + "registers only the %s provider initiation and callback pair", + async (_providerName, selectedInitiation, selectedCallback, otherInitiation, otherCallback) => { + const runtime = authentication({}, true, 0, _providerName) + const baseUrl = await serve(runtime) + + expect((await fetch(`${baseUrl}${selectedInitiation}`, { method: "POST" })).status).toBe(403) + expect((await fetch(`${baseUrl}${selectedCallback}`, { redirect: "manual" })).status).toBe( + 303, + ) + expect( + ( + await fetch(`${baseUrl}${otherInitiation}`, { + method: "POST", + headers: csrfHeaders, + }) + ).status, + ).toBe(404) + expect((await fetch(`${baseUrl}${otherCallback}`, { redirect: "manual" })).status).toBe(404) + }, + ) + it("sets host-bound secure cookies and omits repository scope during GitHub initiation", async () => { const runtime = authentication({ start: vi.fn().mockResolvedValue({ diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index a27ca1a1..31e1afb1 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -70,18 +70,26 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi limit: 60, windowMilliseconds: 5 * 60_000, }) - app.post("/api/auth/github", initiationLimit, handleApiAuthGithubPost.bind(null, authentication)) - app.get( - "/api/auth/github/callback", - callbackLimit, - handleApiAuthGithubCallbackGet.bind(null, authentication), - ) - app.post("/api/auth/oidc", initiationLimit, handleApiAuthOidcPost.bind(null, authentication)) - app.get( - "/api/auth/oidc/callback", - callbackLimit, - handleApiAuthOidcCallbackGet.bind(null, authentication), - ) + if (authentication.settings.provider === "github") { + app.post( + "/api/auth/github", + initiationLimit, + handleApiAuthGithubPost.bind(null, authentication), + ) + app.get( + "/api/auth/github/callback", + callbackLimit, + handleApiAuthGithubCallbackGet.bind(null, authentication), + ) + } + if (authentication.settings.provider === "oidc") { + app.post("/api/auth/oidc", initiationLimit, handleApiAuthOidcPost.bind(null, authentication)) + app.get( + "/api/auth/oidc/callback", + callbackLimit, + handleApiAuthOidcCallbackGet.bind(null, authentication), + ) + } app.get("/api/session", handleApiSessionGet.bind(null, authentication)) app.delete("/api/session", handleApiSessionDelete.bind(null, authentication)) app.post( From 23e6423d61161d1e8e5a9fb09956a04f43761f27 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 14:04:21 -0700 Subject: [PATCH 45/56] test(hub): configure runtime provider fixture Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../domains/authentication/create-runtime/index.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hub/server/domains/authentication/create-runtime/index.test.ts b/hub/server/domains/authentication/create-runtime/index.test.ts index 6e508317..1e68b463 100644 --- a/hub/server/domains/authentication/create-runtime/index.test.ts +++ b/hub/server/domains/authentication/create-runtime/index.test.ts @@ -28,6 +28,7 @@ describe("createAuthenticationRuntime()", () => { "keeps unset artifact storage outside the checkout for %j", async (configuredPath) => { await createAuthenticationRuntime({ + HUB_AUTH_PROVIDER: "github", ...(configuredPath === undefined ? {} : { HUB_ARTIFACT_STORAGE_PATH: configuredPath }), }) @@ -43,7 +44,10 @@ describe("createAuthenticationRuntime()", () => { it("passes a configured relative storage path unchanged to the storage adapter", async () => { await expect( - createAuthenticationRuntime({ HUB_ARTIFACT_STORAGE_PATH: "relative/artifacts" }), + createAuthenticationRuntime({ + HUB_AUTH_PROVIDER: "github", + HUB_ARTIFACT_STORAGE_PATH: "relative/artifacts", + }), ).rejects.toThrow("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") expect(createLocalArtifactStorage).toHaveBeenCalledWith("relative/artifacts") From e80bd6cb7784b6d6f0e16e018ed0a035105dd16c Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 15:55:38 -0700 Subject: [PATCH 46/56] fix(hub): close OIDC review gaps Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/hub-ci.yml | 12 +- CONTRIBUTING.md | 1 + docs/testing-and-review.md | 2 +- hub/docs/identity.md | 2 +- hub/docs/testing-and-operations.md | 1 + hub/package.json | 1 + .../migration.sql | 15 ++ hub/script/setup-entra-oidc.ps1 | 17 +- hub/script/tests/setup-entra-oidc.test.mjs | 26 +++ .../complete-oidc-authorization/index.ts | 35 ++- .../create-runtime/index.test.ts | 17 +- .../authentication/create-runtime/index.ts | 2 +- .../create-service/index.db.test.ts | 216 ++++++++++++++++++ .../create-service/oidc.test.ts | 84 +++++++ .../read-oidc-configuration/index.test.ts | 15 +- .../read-oidc-configuration/index.ts | 5 +- .../identity/create-store/index.db.test.ts | 60 +++++ scripts/check-ci-workflows/index.test.ts | 15 ++ scripts/check-ci-workflows/index.ts | 1 + 19 files changed, 500 insertions(+), 27 deletions(-) diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index ab189513..a6b283e9 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -20,7 +20,17 @@ jobs: fail-fast: false matrix: node: [24, 26] - command: [lint, typecheck, test, build, storybook:build, "db:validate", "test:database"] + command: + [ + lint, + typecheck, + test, + "test:entra-setup", + build, + storybook:build, + "db:validate", + "test:database", + ] services: postgres: image: ${{ matrix.command == 'test:database' && 'postgres:18.6-bookworm' || '' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6760d912..63da68f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,7 @@ cd starter npm ci npm run typecheck npm test +npm run test:entra-setup npm run build ``` diff --git a/docs/testing-and-review.md b/docs/testing-and-review.md index 4ffdcbb3..85182986 100644 --- a/docs/testing-and-review.md +++ b/docs/testing-and-review.md @@ -44,7 +44,7 @@ When a command gains a dependency or build prerequisite, update its local setup All CI workflows run on every pull request and push to `main`. Do not skip checks based on changed paths or add jobs that only combine other jobs' results. Configure branch protection to require the individual checks. -Hub reports lint, types, unit tests, database tests, the frontend/API build, Storybook, and Controls/extension checks separately. Only the database test command starts PostgreSQL. The new Hub has no production-image or release jobs. +Hub reports lint, types, unit tests, Entra setup-script tests, database tests, the frontend/API build, Storybook, and Controls/extension checks separately. Only the database test command starts PostgreSQL. The new Hub has no production-image or release jobs. Maintainers must update branch rules when required checks change. Editing a workflow does not update those rules. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 88194d20..8e7fd7f3 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,6 +1,6 @@ # Hub identity -Hub uses one configured user sign-in provider to prove an external identity. Hub then applies its own installation admission and membership rules before creating a provider-neutral browser session. Provider assignment and repository permissions do not grant future prototype authorization. +Hub uses one configured user sign-in provider to prove an external identity. Hub then applies its installation admission and membership rules before creating a provider-neutral browser session. Entra app-role assignments do not grant prototype access. Repository permissions affect installation admission only through the explicitly selected GitHub synchronization policy, and affect prototype access only when a prototype separately opts into a synchronized repository baseline. ## Production OIDC diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 190250db..e4a0569e 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -9,6 +9,7 @@ npm run lint npm run db:validate npm run typecheck npm test +npm run test:entra-setup npm run test:database npm run build npm run storybook:build diff --git a/hub/package.json b/hub/package.json index 7d1d8a04..1e1d6cae 100644 --- a/hub/package.json +++ b/hub/package.json @@ -49,6 +49,7 @@ "storybook": "STORYBOOK_DISABLE_TELEMETRY=1 storybook dev --host 0.0.0.0 --port 6006 --initial-path /story/hub-design-overview--taxonomy", "storybook:build": "STORYBOOK_DISABLE_TELEMETRY=1 storybook build --output-dir storybook-static", "test": "vitest run --project=node --project=browser", + "test:entra-setup": "node --test script/tests/setup-entra-oidc.test.mjs", "test:database": "vitest run --project=node-database --project=browser-database --passWithNoTests", "typecheck": "tsc --noEmit && tsc --project ../.github/extensions/replica-prototype/tsconfig.json" }, diff --git a/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql b/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql index 8eadefa6..1865f13f 100644 --- a/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql +++ b/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql @@ -2,3 +2,18 @@ ALTER TABLE "external_identities" ADD COLUMN "directory_object_id" TEXT; CREATE UNIQUE INDEX "external_identities_provider_issuer_directory_object_id_key" ON "external_identities"("provider", "issuer", "directory_object_id"); + +ALTER TABLE "installation_memberships" +DROP CONSTRAINT "installation_memberships_source"; + +ALTER TABLE "installation_memberships" +ADD CONSTRAINT "installation_memberships_source" +CHECK ("source" IN ( + 'bootstrap', + 'invitation', + 'legacy-synchronized', + 'manual', + 'oidc-assignment', + 'recovery', + 'synchronized' +)); diff --git a/hub/script/setup-entra-oidc.ps1 b/hub/script/setup-entra-oidc.ps1 index 3e629588..c2720941 100644 --- a/hub/script/setup-entra-oidc.ps1 +++ b/hub/script/setup-entra-oidc.ps1 @@ -23,7 +23,7 @@ param( [ValidateRange(1, 2147483647)] [int] $ProviderRevision = 1, - [ValidatePattern("^[A-Z][A-Z0-9_]{2,127}$")] + [ValidatePattern("(?-i)^[A-Z][A-Z0-9_]{2,127}$")] [string] $ClientCredentialReference = "HUB_OIDC_CLIENT_SECRET", [ValidateSet("jit-assignment", "invitation-required")] @@ -199,10 +199,8 @@ function Assert-ManagedRoleIdsAvailable { param($Application) foreach ($role in @($Application.AppRoles)) { - if (($role.Id -eq $userRoleId -and $role.Value -ne "Hub.User") -or - ($role.Id -eq $administratorRoleId -and $role.Value -ne "Hub.Administrator") -or - ($role.Value -eq "Hub.User" -and $role.Id -ne $userRoleId) -or - ($role.Value -eq "Hub.Administrator" -and $role.Id -ne $administratorRoleId)) { + if (($role.Value -ceq "Hub.User" -and $role.Id -ne $userRoleId) -or + ($role.Value -ceq "Hub.Administrator" -and $role.Id -ne $administratorRoleId)) { throw "The application has a conflicting Replica Hub app-role ID or value." } } @@ -213,7 +211,10 @@ function Get-ConvergedRoles { Assert-ManagedRoleIdsAvailable -Application $Application $otherRoles = @($Application.AppRoles).Where({ - $_.Value -notin @("Hub.User", "Hub.Administrator") + $_.Id -ne $userRoleId -and + $_.Id -ne $administratorRoleId -and + $_.Value -cne "Hub.User" -and + $_.Value -cne "Hub.Administrator" }).ForEach({ @{ Id = $_.Id @@ -243,7 +244,7 @@ function Test-ApplicationDrift { } foreach ($desired in Get-AppRoles) { - $actual = @($Application.AppRoles).Where({ $_.Value -eq $desired.Value }) + $actual = @($Application.AppRoles).Where({ $_.Value -ceq $desired.Value }) if ($actual.Count -ne 1) { return $true } $actualRole = $actual[0] if ($actualRole.Id -ne $desired.Id -or @@ -270,7 +271,7 @@ function Get-ConvergedRedirectUris { return @($CallbackUrl) } $redirectUris = @($Application.Web.RedirectUris) + @($CallbackUrl) - return @($redirectUris | Sort-Object -Unique) + return @($redirectUris | Sort-Object -CaseSensitive -Unique) } function Get-ServicePrincipal { diff --git a/hub/script/tests/setup-entra-oidc.test.mjs b/hub/script/tests/setup-entra-oidc.test.mjs index b5e7ced5..9f7ba49d 100644 --- a/hub/script/tests/setup-entra-oidc.test.mjs +++ b/hub/script/tests/setup-entra-oidc.test.mjs @@ -96,6 +96,32 @@ describe("setup-entra-oidc.ps1", () => { assert.doesNotMatch(drift, /\bCompare-Object\b/) }) + it("keeps client credential references case-sensitive", () => { + assert.match( + script, + /\[ValidatePattern\("\(\?-i\)\^\[A-Z\]\[A-Z0-9_\]\{2,127\}\$"\)\]\s*\[string\] \$ClientCredentialReference/, + ) + }) + + it("matches and preserves app-role values case-sensitively", () => { + const available = functionBody("Assert-ManagedRoleIdsAvailable") + const converged = functionBody("Get-ConvergedRoles") + const drift = functionBody("Test-ApplicationDrift") + + assert.match(available, /\$role\.Value -ceq "Hub\.User"/) + assert.match(available, /\$role\.Value -ceq "Hub\.Administrator"/) + assert.match(converged, /\$_.Value -cne "Hub\.User"/) + assert.match(converged, /\$_.Value -cne "Hub\.Administrator"/) + assert.match(drift, /\$_.Value -ceq \$desired\.Value/) + }) + + it("deduplicates redirect URIs without collapsing case variants", () => { + const redirects = functionBody("Get-ConvergedRedirectUris") + + assert.match(redirects, /Sort-Object -CaseSensitive -Unique/) + assert.doesNotMatch(redirects, /Sort-Object -Unique/) + }) + it("limits obsolete assignments to managed group roles and preserves unrelated assignments", () => { const assignments = functionBody("Get-AssignmentChanges") diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts index 489d539a..5bd16f17 100644 --- a/hub/server/domains/authentication/complete-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -104,11 +104,7 @@ export async function completeOidcAuthorization( targetMembershipId: membership?.id ?? null, context: { reason: "provider_role_changed" }, }) - throw new AuthenticationError( - 403, - "provider_role_changed", - "Your provider role changed. Sign in again.", - ) + return { providerRoleChanged: true as const } } await records.verifySession(session.id, now()) await records.audit({ @@ -133,6 +129,26 @@ export async function completeOidcAuthorization( return { denied: true as const } } let identity = await records.externalIdentity("oidc", oidcConfiguration.issuer, profile.subject) + const directoryIdentity = profile.directoryObjectId + ? await records.externalIdentityByDirectoryObjectId( + "oidc", + oidcConfiguration.issuer, + profile.directoryObjectId, + ) + : null + if (!identity && directoryIdentity) { + const directoryMembership = await records.membership(directoryIdentity.userId) + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: directoryIdentity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: directoryMembership?.id ?? null, + context: { reason: "provider_subject_changed" }, + }) + return { denied: true as const } + } let membership = identity ? await records.membership(identity.userId) : null const invitationSubject = profile.directoryObjectId const invitation = @@ -317,7 +333,7 @@ export async function completeOidcAuthorization( sessionId: null, authenticationProviderRevisionId: oidcProviderRevisionId, targetMembershipId: membership.id, - context: { role: profile.role }, + context: { reason: `role_${profile.role}` }, }) } if (!membership || membership.status !== "active") return { denied: true as const } @@ -342,5 +358,12 @@ export async function completeOidcAuthorization( if ("denied" in result && result.denied) { throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") } + if ("providerRoleChanged" in result && result.providerRoleChanged) { + throw new AuthenticationError( + 403, + "provider_role_changed", + "Your provider role changed. Sign in again.", + ) + } return result } diff --git a/hub/server/domains/authentication/create-runtime/index.test.ts b/hub/server/domains/authentication/create-runtime/index.test.ts index 1e68b463..f3524c70 100644 --- a/hub/server/domains/authentication/create-runtime/index.test.ts +++ b/hub/server/domains/authentication/create-runtime/index.test.ts @@ -3,7 +3,7 @@ import path from "node:path" import { describe, expect, it, vi } from "vitest" -const { createLocalArtifactStorage } = vi.hoisted(() => ({ +const { createLocalArtifactStorage, ensureOidcProviderRevision } = vi.hoisted(() => ({ createLocalArtifactStorage: vi.fn((root: string) => { if (!path.isAbsolute(root)) { throw new Error("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") @@ -14,11 +14,15 @@ const { createLocalArtifactStorage } = vi.hoisted(() => ({ remove: vi.fn(), } }), + ensureOidcProviderRevision: vi.fn(), })) vi.mock("#server/integrations/artifact-storage/create-local-storage", () => ({ createLocalArtifactStorage, })) +vi.mock("#server/domains/authentication/ensure-oidc-provider-revision", () => ({ + ensureOidcProviderRevision, +})) vi.mock("#server/integrations/prisma", () => ({ prisma: {} })) import { createAuthenticationRuntime } from "#server/domains/authentication/create-runtime" @@ -52,4 +56,15 @@ describe("createAuthenticationRuntime()", () => { expect(createLocalArtifactStorage).toHaveBeenCalledWith("relative/artifacts") }) + + it("rejects an incompatible OIDC admission policy before persisting a provider revision", async () => { + await expect( + createAuthenticationRuntime({ + HUB_AUTH_PROVIDER: "oidc", + HUB_ADMISSION_POLICY: "github-synchronized", + }), + ).rejects.toThrow("OIDC authentication requires HUB_ADMISSION_POLICY=hub-managed.") + + expect(ensureOidcProviderRevision).not.toHaveBeenCalled() + }) }) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 973075e3..f6fb5520 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -76,6 +76,7 @@ function readHostSecret(environment: NodeJS.ProcessEnv, key: string): string | n export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv) { const origin = readOrigin(environment) const provider = readAuthenticationProvider(environment) + ensureProviderAdmissionPolicy(provider, environment) const configuration = provider === "github" ? readGitHubAuthentication(environment, origin) : null const oidcConfiguration = provider === "oidc" ? readOidcConfiguration(environment, origin) : null const store = createIdentityStore(prisma) @@ -90,7 +91,6 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv const oidcProviderRevision = oidcConfiguration ? await ensureOidcProviderRevision(store, oidcConfiguration) : null - ensureProviderAdmissionPolicy(provider, environment) const admissionPolicy = createAdmissionPolicy(environment, store, githubAuthorization) const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 7ee74845..2f5e0446 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -2,10 +2,26 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createAuthenticationService } from "#server/domains/authentication/create-service" import { createIdentityStore } from "#server/domains/identity/create-store" +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" import { digestSecret } from "#server/utils/digest-secret" type IdentityStore = ReturnType +const oidcIssuer = "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0" +const oidcConfiguration: OidcConfiguration = { + issuer: oidcIssuer, + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "jit-assignment", + enabled: true, + providerRevision: 4, +} + async function database() { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") @@ -13,6 +29,71 @@ async function database() { return { prisma, store: createIdentityStore(prisma) } } +async function createOidcMember(store: IdentityStore, role: "member" | "administrator") { + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: "OIDC member", + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: oidcIssuer, + subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + username: "member@example.test", + email: "member@example.test", + displayName: "OIDC member", + avatarUrl: null, + }) + const provider = await records.createAuthenticationProviderRevision({ + key: "oidc", + type: "oidc", + enabled: true, + revision: 4, + issuer: oidcIssuer, + configuration: { clientId: "hub-client" }, + }) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role, + source: "oidc-assignment", + }) + return { identity, membership, provider, user } + }) +} + +function oidcService( + store: IdentityStore, + providerRevisionId: string, + role: "member" | "administrator", + subject = "immutable-subject", +) { + return createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + oidc: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject, + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + username: "member@example.test", + email: "member@example.test", + displayName: "OIDC member", + avatarUrl: null, + role, + }), + }, + oidcConfiguration, + oidcProviderRevisionId: providerRevisionId, + provider: "oidc", + admissionPolicy: { name: "hub-managed", admit: vi.fn().mockResolvedValue(false) }, + }) +} + async function createAdministrator(store: IdentityStore) { return store.lock(async (records) => { const user = await records.createUser({ @@ -330,4 +411,139 @@ describe("createAuthenticationService() database behavior", () => { }), ]) }) + + it("commits a verification role-change denial before returning the error", async () => { + const { prisma, store } = await database() + const graph = await createOidcMember(store, "administrator") + const signedInAt = new Date() + const session = await store.lock((records) => + records.createSession({ + userId: graph.user.id, + externalIdentityId: graph.identity.id, + authenticationProviderRevisionId: graph.provider.id, + tokenDigest: digestSecret("session-token"), + signedInAt, + freshAuthorizedAt: null, + }), + ) + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: session.id, + stateDigest: digestSecret("verify-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "nonce", + purpose: "verify", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + + await expect( + oidcService(store, graph.provider.id, "member").complete( + "provider-code", + "verify-state", + "browser-token", + ), + ).rejects.toMatchObject({ status: 403, code: "provider_role_changed" }) + + expect(await prisma.session.findUnique({ where: { id: session.id } })).toMatchObject({ + freshAuthorizedAt: null, + }) + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "session.verify", + outcome: "denied", + actorUserId: graph.user.id, + sessionId: session.id, + targetMembershipId: graph.membership.id, + context: { reason: "provider_role_changed" }, + }), + ]) + }) + + it("denies a changed provider subject without violating the directory object ID uniqueness", async () => { + const { prisma, store } = await database() + const graph = await createOidcMember(store, "member") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("changed-subject-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "nonce", + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + + await expect( + oidcService(store, graph.provider.id, "member", "replacement-subject").complete( + "provider-code", + "changed-subject-state", + "browser-token", + ), + ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) + + expect(await prisma.externalIdentity.count({ where: { userId: graph.user.id } })).toBe(1) + expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(0) + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: graph.user.id, + targetMembershipId: graph.membership.id, + context: { reason: "provider_subject_changed" }, + }), + ]) + }) + + it("commits a provider role refresh and its supported audit context together", async () => { + const { prisma, store } = await database() + const graph = await createOidcMember(store, "member") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("sign-in-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "nonce", + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + + await expect( + oidcService(store, graph.provider.id, "administrator").complete( + "provider-code", + "sign-in-state", + "browser-token", + ), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect( + await prisma.installationMembership.findUnique({ where: { id: graph.membership.id } }), + ).toMatchObject({ role: "administrator", source: "oidc-assignment" }) + expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(1) + const audits = await store.read.audits() + expect(audits).toHaveLength(2) + expect(audits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "membership.provider_role.refresh", + outcome: "succeeded", + actorUserId: graph.user.id, + targetMembershipId: graph.membership.id, + context: { reason: "role_administrator" }, + }), + expect.objectContaining({ + event: "session.sign_in", + outcome: "succeeded", + actorUserId: graph.user.id, + }), + ]), + ) + }) }) diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index f090b865..2086dcd3 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -463,6 +463,38 @@ describe("createAuthenticationService() OIDC", () => { }) }) + it("denies a changed provider subject that reuses an existing directory object ID", async () => { + const identity = existingIdentity() + const membership = activeMembership() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentityByDirectoryObjectId: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(membership), + }) + const client = oidc({ + identify: vi.fn().mockResolvedValue(profile({ subject: "replacement-subject" })), + }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: "user", + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: membership.id, + context: { reason: "provider_subject_changed" }, + }) + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.saveExternalIdentity).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + it("refreshes authorization only when the active membership still matches the signed provider role", async () => { const session = { id: "session", @@ -555,6 +587,58 @@ describe("createAuthenticationService() OIDC", () => { ) }) + it.each([ + ["member", "administrator", "role_administrator"], + ["administrator", "member", "role_member"], + ] as const)( + "refreshes an active %s to %s and audits the supported reason", + async (currentRole, providerRole, reason) => { + const membership = activeMembership(currentRole) + const refreshedMembership = { ...membership, role: providerRole } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), + membership: vi.fn().mockResolvedValueOnce(membership).mockResolvedValueOnce(membership), + activeAdministratorCount: vi.fn().mockResolvedValue(2), + saveMembership: vi.fn().mockResolvedValue(refreshedMembership), + }) + const { authentication } = service( + identityRecords, + oidc({ identify: vi.fn().mockResolvedValue(profile({ role: providerRole })) }), + ) + + await expect( + authentication.complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(identityRecords.saveMembership).toHaveBeenCalledWith({ + userId: membership.userId, + status: "active", + role: providerRole, + source: "oidc-assignment", + suspendedAt: null, + removedAt: null, + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.provider_role.refresh", + outcome: "succeeded", + actorUserId: "user", + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: membership.id, + context: { reason }, + }) + expect(identityRecords.saveMembership.mock.invocationCallOrder[0]).toBeLessThan( + identityRecords.audit.mock.invocationCallOrder.find( + (order, index) => + identityRecords.audit.mock.calls[index]?.[0].event === + "membership.provider_role.refresh", + )!, + ) + expect(identityRecords.createSession).toHaveBeenCalledOnce() + }, + ) + it("keeps a suspended member denied even when current provider claims still grant a role", async () => { const identityRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.test.ts b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts index eacbf30b..5e31a321 100644 --- a/hub/server/domains/authentication/read-oidc-configuration/index.test.ts +++ b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts @@ -123,13 +123,14 @@ describe("readOidcConfiguration()", () => { ).toThrow("HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.") }) - it("rejects offline_access so Hub cannot receive a provider refresh token", () => { - expect(() => - readOidcConfiguration( - environment({ HUB_OIDC_SCOPES: "openid profile email offline_access" }), - origin, - ), - ).toThrow("HUB_OIDC_SCOPES must include openid and must not request offline_access.") + it.each([ + ["without openid", "profile email"], + ["without profile", "openid email"], + ["with offline_access", "openid profile email offline_access"], + ])("rejects scopes %s", (_description, scopes) => { + expect(() => readOidcConfiguration(environment({ HUB_OIDC_SCOPES: scopes }), origin)).toThrow( + "HUB_OIDC_SCOPES must include openid and profile and must not request offline_access.", + ) }) it("rejects an unknown membership policy instead of falling back to JIT admission", () => { diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.ts b/hub/server/domains/authentication/read-oidc-configuration/index.ts index 9d3a8358..2e19be49 100644 --- a/hub/server/domains/authentication/read-oidc-configuration/index.ts +++ b/hub/server/domains/authentication/read-oidc-configuration/index.ts @@ -43,10 +43,13 @@ export function readOidcConfiguration( .filter(Boolean) if ( !scopes.includes("openid") || + !scopes.includes("profile") || scopes.includes("offline_access") || scopes.some((scope) => !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope)) ) { - throw new Error("HUB_OIDC_SCOPES must include openid and must not request offline_access.") + throw new Error( + "HUB_OIDC_SCOPES must include openid and profile and must not request offline_access.", + ) } const memberRole = requiredEnvironmentValue(environment, "HUB_OIDC_MEMBER_ROLE") const administratorRole = requiredEnvironmentValue(environment, "HUB_OIDC_ADMINISTRATOR_ROLE") 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 836e8ddc..51773cce 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises" + import { describe, expect, it, onTestFinished, vi } from "vitest" import { createIdentityStore } from "#server/domains/identity/create-store" @@ -270,6 +272,64 @@ describe("createIdentityStore()", () => { expect(user).not.toHaveProperty("administrator") }) + it("applies the migration constraint for every current installation membership source", async () => { + const { prisma, store } = await database() + await prisma.$executeRawUnsafe(` + ALTER TABLE "installation_memberships" + ADD CONSTRAINT "installation_memberships_source" + CHECK ("source" IN ('bootstrap', 'invitation', 'manual', 'recovery', 'synchronized')) + `) + const migration = await readFile( + new URL( + "../../../../schema/migrations/20261028210000_oidc_review_hardening/migration.sql", + import.meta.url, + ), + "utf8", + ) + const constraintSql = migration.slice( + migration.indexOf('ALTER TABLE "installation_memberships"'), + ) + await prisma.$executeRawUnsafe(constraintSql) + + for (const source of [ + "bootstrap", + "invitation", + "legacy-synchronized", + "manual", + "oidc-assignment", + "recovery", + "synchronized", + ] as const) { + const user = await store.lock((records) => + records.createUser({ displayName: `Source ${source}`, avatarUrl: null }), + ) + await expect( + store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source, + }), + ), + ).resolves.toMatchObject({ source }) + } + + const unsupportedUser = await store.lock((records) => + records.createUser({ displayName: "Unsupported source", avatarUrl: null }), + ) + await expect( + prisma.installationMembership.create({ + data: { + userId: unsupportedUser.id, + status: "active", + role: "member", + source: "provider-default", + }, + }), + ).rejects.toThrow() + }) + it("stores OIDC provider revisions separately from GitHub integration credentials", async () => { const { prisma, store } = await database() const revision = await store.lock((records) => diff --git a/scripts/check-ci-workflows/index.test.ts b/scripts/check-ci-workflows/index.test.ts index 070d3d45..9d3e5d13 100644 --- a/scripts/check-ci-workflows/index.test.ts +++ b/scripts/check-ci-workflows/index.test.ts @@ -33,6 +33,21 @@ describe("checkWorkflows()", () => { expect(() => checkWorkflows(root)).not.toThrow() }) + it("runs the Entra setup contract through the Hub package command on both Node versions", () => { + const workflow = parse( + readFileSync(join(root, ".github/workflows/hub-ci.yml"), "utf8"), + ) as Workflow + const manifest = JSON.parse(readFileSync(join(root, "hub/package.json"), "utf8")) + + expect(manifest.scripts["test:entra-setup"]).toBe( + "node --test script/tests/setup-entra-oidc.test.mjs", + ) + expect(workflow.jobs.check.strategy?.matrix.command).toContain("test:entra-setup") + expect(workflow.jobs.check.steps).toContainEqual( + expect.objectContaining({ run: "npm run ${{ matrix.command }}" }), + ) + }) + it("rejects an aggregate result gate", () => { const directory = fixture() const path = join(directory, ".github/workflows/hub-ci.yml") diff --git a/scripts/check-ci-workflows/index.ts b/scripts/check-ci-workflows/index.ts index 3189987d..67ea26dc 100644 --- a/scripts/check-ci-workflows/index.ts +++ b/scripts/check-ci-workflows/index.ts @@ -68,6 +68,7 @@ export function checkWorkflows(root: string): void { "lint", "typecheck", "test", + "test:entra-setup", "build", "storybook:build", "db:validate", From 962bf53c19833cbce5e639c72afdaff82ae0817e Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 16:45:01 -0700 Subject: [PATCH 47/56] fix(hub): preserve OIDC role ownership Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/entra-oidc.md | 2 +- hub/docs/membership-and-administration.md | 2 +- hub/script/setup-entra-oidc.ps1 | 4 +- hub/script/tests/setup-entra-oidc.test.mjs | 8 ++ .../create-service/index.db.test.ts | 68 +++++++++++- .../administration/create-service/index.ts | 9 ++ .../complete-oidc-authorization/index.ts | 21 +++- .../create-service/index.db.test.ts | 52 +++++++++ .../create-service/oidc.test.ts | 82 +++++++++++++- .../read-oidc-configuration/index.test.ts | 16 ++- .../read-oidc-configuration/index.ts | 15 +-- .../identity/create-store/index.db.test.ts | 105 ++++++++++-------- .../utils/normalize-entra-issuer/index.ts | 8 ++ 13 files changed, 318 insertions(+), 74 deletions(-) create mode 100644 hub/server/utils/normalize-entra-issuer/index.ts diff --git a/hub/docs/entra-oidc.md b/hub/docs/entra-oidc.md index e87523e5..31088634 100644 --- a/hub/docs/entra-oidc.md +++ b/hub/docs/entra-oidc.md @@ -47,7 +47,7 @@ HUB_OIDC_PROVIDER_REVISION=1 The callback must exactly match `HUB_ORIGIN` plus `/api/auth/oidc/callback`. The first supported profile requires the issuer `https://login.microsoftonline.com//v2.0`; Hub does not accept a generic hostname or tenant alias. Discovered authorization, token, and JWKS endpoints must use that same HTTPS origin. Increase `HUB_OIDC_PROVIDER_REVISION` whenever any OIDC setting or referenced credential changes. Hub rejects revision rollback and same-revision configuration drift. -`jit-assignment` creates an `oidc-assignment` membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. `invitation-required` also requires a matching Hub invitation. For Entra invitations, enter the user's tenant directory object ID (`oid`), which an administrator can copy from the user's **Object ID** in the Entra admin center or read through approved Microsoft Graph directory tooling. Hub keeps the application-specific OIDC `sub` as the external sign-in identity and uses the tenant-scoped `oid` only to match the pre-created invitation. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. An administrator claim may promote an active member. Hub refuses a claim-driven demotion that would remove the final active administrator. +`jit-assignment` creates an `oidc-assignment` membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. Those provider-owned memberships continue to track the current mapped app role. `invitation-required` also requires a matching Hub invitation. For Entra invitations, enter the user's tenant directory object ID (`oid`), which an administrator can copy from the user's **Object ID** in the Entra admin center or read through approved Microsoft Graph directory tooling. Hub keeps the application-specific OIDC `sub` as the external sign-in identity and uses the tenant-scoped `oid` only to match the pre-created invitation. Invitation, bootstrap, recovery, and manually changed memberships preserve their Hub-owned role instead of being overwritten by later app-role claims. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. Hub refuses a claim-driven demotion of an `oidc-assignment` membership that would remove the final active administrator. ## Identity and lifecycle boundaries diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index f70a3f8e..0d9007d8 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -2,7 +2,7 @@ Hub owns installation admission after an authentication provider proves an identity. Repository access affects installation membership only through the explicit GitHub-synchronized policy; Hub remains the final evaluator. -For OIDC, a configured member or administrator app role is required before Hub evaluates membership. Under `jit-assignment`, a first successful sign-in may create an `oidc-assignment` membership from that assignment. Under `invitation-required`, an Entra administrator creates the invitation with the tenant issuer and the user's directory Object ID (`oid`) from the Entra admin center or approved Graph tooling. Hub matches that signed tenant-scoped claim while retaining the application-specific OIDC `sub` as the external identity. Suspended membership remains suspended. Removed membership requires a new invitation and is never silently reactivated from a provider claim. +For OIDC, a configured member or administrator app role is required before Hub evaluates membership. Under `jit-assignment`, a first successful sign-in may create an `oidc-assignment` membership from that assignment; only this provider-owned source tracks later app-role changes. Under `invitation-required`, an Entra administrator creates the invitation with the tenant issuer and the user's directory Object ID (`oid`) from the Entra admin center or approved Graph tooling. Hub matches that signed tenant-scoped claim while retaining the application-specific OIDC `sub` as the external identity. Invitation, bootstrap, recovery, and manually changed membership roles remain Hub-owned. Suspended membership remains suspended. Removed membership requires a new invitation and is never silently reactivated from a provider claim. ## Membership contract diff --git a/hub/script/setup-entra-oidc.ps1 b/hub/script/setup-entra-oidc.ps1 index c2720941..f9b3810b 100644 --- a/hub/script/setup-entra-oidc.ps1 +++ b/hub/script/setup-entra-oidc.ps1 @@ -20,13 +20,13 @@ param( [ValidateNotNullOrEmpty()] [string] $DisplayName = "Replica Hub", - [ValidateRange(1, 2147483647)] + [ValidateRange(1, 2147483646)] [int] $ProviderRevision = 1, [ValidatePattern("(?-i)^[A-Z][A-Z0-9_]{2,127}$")] [string] $ClientCredentialReference = "HUB_OIDC_CLIENT_SECRET", - [ValidateSet("jit-assignment", "invitation-required")] + [ValidateSet("jit-assignment", "invitation-required", IgnoreCase = $false)] [string] $MembershipPolicy = "jit-assignment", [string[]] $UserGroupId = @(), diff --git a/hub/script/tests/setup-entra-oidc.test.mjs b/hub/script/tests/setup-entra-oidc.test.mjs index 9f7ba49d..61ac72ed 100644 --- a/hub/script/tests/setup-entra-oidc.test.mjs +++ b/hub/script/tests/setup-entra-oidc.test.mjs @@ -103,6 +103,14 @@ describe("setup-entra-oidc.ps1", () => { ) }) + it("keeps membership policy casing and provider revision bounds aligned with Hub", () => { + assert.match(script, /\[ValidateRange\(1, 2147483646\)\]\s*\[int\] \$ProviderRevision/) + assert.match( + script, + /\[ValidateSet\("jit-assignment", "invitation-required", IgnoreCase = \$false\)\]\s*\[string\] \$MembershipPolicy/, + ) + }) + it("matches and preserves app-role values case-sensitively", () => { const available = functionBody("Assert-ManagedRoleIdsAvailable") const converged = functionBody("Get-ConvergedRoles") diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index 2f7595b1..55220258 100644 --- a/hub/server/domains/administration/create-service/index.db.test.ts +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -6,6 +6,8 @@ import { digestSecret } from "#server/utils/digest-secret" type IdentityStore = ReturnType +const entraIssuer = "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0" + async function database() { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") @@ -80,7 +82,7 @@ describe("createAdministrationService() database behavior", () => { await records.saveExternalIdentity({ userId: user.id, provider: "oidc", - issuer: "https://login.example.test/tenant", + issuer: entraIssuer, subject: "provider-subject", directoryObjectId, username: "member@example.test", @@ -102,7 +104,7 @@ describe("createAdministrationService() database behavior", () => { await expect( service.invite("actor-token", { provider: "oidc", - issuer: "https://login.example.test/tenant", + issuer: "https://login.microsoftonline.com/A0B1C2D3-E4F5-4A67-8B9C-0D1E2F3A4B5C/v2.0/", subject: directoryObjectId.toUpperCase(), role: "member", }), @@ -113,6 +115,68 @@ describe("createAdministrationService() database behavior", () => { expect(await store.read.invitations()).toEqual([]) }) + it("normalizes an Entra invitation before persistence and duplicate lookup", async () => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + const directoryObjectId = "b1e2d3c4-a5b6-4789-abcd-ef0123456789" + + await expect( + service.invite("actor-token", { + provider: "oidc", + issuer: "https://login.microsoftonline.com/A0B1C2D3-E4F5-4A67-8B9C-0D1E2F3A4B5C/v2.0/", + subject: directoryObjectId.toUpperCase(), + role: "member", + }), + ).resolves.toMatchObject({ + issuer: entraIssuer, + subject: directoryObjectId, + }) + + await expect( + service.invite("actor-token", { + provider: "oidc", + issuer: entraIssuer, + subject: directoryObjectId, + role: "member", + }), + ).rejects.toMatchObject({ + status: 409, + code: "invitation_exists", + }) + expect(await store.read.invitations()).toEqual([ + expect.objectContaining({ + issuer: entraIssuer, + subject: directoryObjectId, + }), + ]) + }) + + it("rejects an OIDC invitation for an unsupported Entra issuer", async () => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.invite("actor-token", { + provider: "oidc", + issuer: "https://login.example.test/tenant/v2.0", + subject: "b1e2d3c4-a5b6-4789-abcd-ef0123456789", + role: "member", + }), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_invitation", + }) + expect(await store.read.invitations()).toEqual([]) + }) + it("serializes overlapping suspensions so one active administrator and its session remain", async () => { const { store } = await database() const first = await createAdministrator(store, "41", "first-token") diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index d747845b..3e1b6c94 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -7,6 +7,7 @@ import type { import { getCurrentSession } from "#server/domains/authentication/get-current-session" import { ensureProductAdministrationContinuity } from "#server/domains/prototype-authorization/ensure-administration-continuity" import { AuthenticationError } from "#server/utils/authentication-error" +import { normalizeEntraIssuer } from "#server/utils/normalize-entra-issuer" const invitationSchema = z .object({ @@ -26,8 +27,16 @@ const invitationSchema = z }) .transform((input) => ({ ...input, + issuer: + input.provider === "oidc" + ? (normalizeEntraIssuer(input.issuer) ?? input.issuer) + : input.issuer, subject: input.provider === "oidc" ? input.subject.toLowerCase() : input.subject, })) + .refine(({ provider, issuer }) => provider !== "oidc" || normalizeEntraIssuer(issuer) !== null, { + path: ["issuer"], + message: "OIDC invitations require a supported Entra issuer.", + }) const membershipChangeSchema = z .object({ diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts index 5bd16f17..8337018a 100644 --- a/hub/server/domains/authentication/complete-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -94,7 +94,11 @@ export async function completeOidcAuthorization( ) { throw new AuthenticationError(403, "identity_mismatch", "Verify the same OIDC identity.") } - if (!profile.role || membership?.status !== "active" || membership.role !== profile.role) { + if ( + !profile.role || + membership?.status !== "active" || + (membership.source === "oidc-assignment" && membership.role !== profile.role) + ) { await records.audit({ event: "session.verify", outcome: "denied", @@ -301,7 +305,20 @@ export async function completeOidcAuthorization( suspendedAt: null, removedAt: null, }) - } else if (membership.status === "active" && membership.role !== profile.role) { + await records.audit({ + event: "membership.oidc_assignment.activate", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + context: { reason: `role_${profile.role}` }, + }) + } else if ( + membership.status === "active" && + membership.source === "oidc-assignment" && + membership.role !== profile.role + ) { if ( membership.role === "administrator" && profile.role === "member" && diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 2f5e0446..5d7f3c36 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -412,6 +412,58 @@ describe("createAuthenticationService() database behavior", () => { ]) }) + it("rolls back a new OIDC assignment when its activation audit cannot be written", async () => { + const { prisma, store } = await database() + const provider = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "oidc", + type: "oidc", + enabled: true, + revision: 4, + issuer: oidcIssuer, + configuration: { clientId: "hub-client" }, + }), + ) + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider.id, + originatingSessionId: null, + stateDigest: digestSecret("new-assignment-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "nonce", + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + + await prisma.$executeRawUnsafe(` + ALTER TABLE "security_audits" + ADD CONSTRAINT "test_reject_oidc_assignment_activation" + CHECK ("event" <> 'membership.oidc_assignment.activate') + `) + try { + await expect( + oidcService(store, provider.id, "member").complete( + "provider-code", + "new-assignment-state", + "browser-token", + ), + ).rejects.toThrow() + + expect(await prisma.user.count()).toBe(0) + expect(await prisma.externalIdentity.count()).toBe(0) + expect(await prisma.installationMembership.count()).toBe(0) + expect(await prisma.session.count()).toBe(0) + expect(await store.read.audits()).toEqual([]) + } finally { + await prisma.$executeRawUnsafe(` + ALTER TABLE "security_audits" + DROP CONSTRAINT IF EXISTS "test_reject_oidc_assignment_activation" + `) + } + }) + it("commits a verification role-change denial before returning the error", async () => { const { prisma, store } = await database() const graph = await createOidcMember(store, "administrator") diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index 2086dcd3..34aab419 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -187,13 +187,16 @@ function service( } } -function activeMembership(role: "member" | "administrator" = "member") { +function activeMembership( + role: "member" | "administrator" = "member", + source: InstallationMembershipInput["source"] = "oidc-assignment", +) { return { id: "membership", userId: "user", status: "active" as const, role, - source: "oidc-assignment" as const, + source, suspendedAt: null, removedAt: null, } @@ -422,6 +425,15 @@ describe("createAuthenticationService() OIDC", () => { suspendedAt: null, removedAt: null, }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.oidc_assignment.activate", + outcome: "succeeded", + actorUserId: "user", + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: "membership", + context: { reason: `role_${role}` }, + }) }, ) @@ -567,6 +579,72 @@ describe("createAuthenticationService() OIDC", () => { expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) }) + it.each(["invitation", "manual", "bootstrap", "recovery"] as const)( + "preserves a %s role override when the provider still grants an admitted role", + async (source) => { + const membership = activeMembership("administrator", source) + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), + membership: vi.fn().mockResolvedValue(membership), + }) + const { authentication } = service( + identityRecords, + oidc({ identify: vi.fn().mockResolvedValue(profile({ role: "member" })) }), + ) + + await expect( + authentication.complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.createSession).toHaveBeenCalledOnce() + }, + ) + + it.each(["invitation", "manual", "bootstrap", "recovery"] as const)( + "verifies a %s role override while requiring an admitted provider role", + async (source) => { + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "digest", + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "Member Name", avatarUrl: null }, + externalIdentity: existingIdentity(), + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi + .fn() + .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + sessionById: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue(activeMembership("administrator", source)), + }) + const client = oidc({ identify: vi.fn().mockResolvedValue(profile({ role: "member" })) }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.complete("code", "state", "browser-token")).resolves.toEqual({ + purpose: "verify", + }) + + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + client.identify.mockResolvedValueOnce(profile({ role: null })) + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject( + { + status: 403, + code: "provider_role_changed", + }, + ) + expect(identityRecords.verifySession).toHaveBeenCalledOnce() + }, + ) + it("refuses a role refresh that would remove the final active administrator", async () => { const identityRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.test.ts b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts index 5e31a321..28e62045 100644 --- a/hub/server/domains/authentication/read-oidc-configuration/index.test.ts +++ b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts @@ -154,12 +154,16 @@ describe("readOidcConfiguration()", () => { }, ) - it("rejects the largest safe integer because a later provider change cannot increment it", () => { + it("accepts the largest incrementable database provider revision", () => { + expect( + readOidcConfiguration(environment({ HUB_OIDC_PROVIDER_REVISION: "2147483646" }), origin) + .providerRevision, + ).toBe(2_147_483_646) + }) + + it("rejects a provider revision that cannot be incremented in the database", () => { expect(() => - readOidcConfiguration( - environment({ HUB_OIDC_PROVIDER_REVISION: String(Number.MAX_SAFE_INTEGER) }), - origin, - ), - ).toThrow("HUB_OIDC_PROVIDER_REVISION must be an incrementable positive integer.") + readOidcConfiguration(environment({ HUB_OIDC_PROVIDER_REVISION: "2147483647" }), origin), + ).toThrow("HUB_OIDC_PROVIDER_REVISION must fit the incrementable database integer range.") }) }) diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.ts b/hub/server/domains/authentication/read-oidc-configuration/index.ts index 2e19be49..d8651278 100644 --- a/hub/server/domains/authentication/read-oidc-configuration/index.ts +++ b/hub/server/domains/authentication/read-oidc-configuration/index.ts @@ -1,4 +1,5 @@ import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" +import { normalizeEntraIssuer } from "#server/utils/normalize-entra-issuer" function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, key: string): string { const value = environment[key] @@ -15,14 +16,10 @@ export function readOidcConfiguration( throw new Error("HUB_OIDC_ENABLED must be true or false.") } const issuerValue = requiredEnvironmentValue(environment, "HUB_OIDC_ISSUER") - const issuerMatch = issuerValue.match( - /^https:\/\/login\.microsoftonline\.com\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/v2\.0\/?$/i, - ) - const tenantId = issuerMatch?.[1] - if (!tenantId) { + const issuer = normalizeEntraIssuer(issuerValue) + if (!issuer) { throw new Error("HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.") } - const issuer = `https://login.microsoftonline.com/${tenantId.toLowerCase()}/v2.0` const callbackUrl = requiredEnvironmentValue(environment, "HUB_OIDC_CALLBACK_URL") if (callbackUrl !== `${origin.origin}/api/auth/oidc/callback`) { throw new Error("HUB_OIDC_CALLBACK_URL must match the Hub OIDC callback URL.") @@ -67,11 +64,11 @@ export function readOidcConfiguration( const providerRevision = Number( requiredEnvironmentValue(environment, "HUB_OIDC_PROVIDER_REVISION"), ) - if (!Number.isSafeInteger(providerRevision) || providerRevision < 1) { + if (!Number.isInteger(providerRevision) || providerRevision < 1) { throw new Error("HUB_OIDC_PROVIDER_REVISION must be a positive integer.") } - if (providerRevision >= Number.MAX_SAFE_INTEGER) { - throw new Error("HUB_OIDC_PROVIDER_REVISION must be an incrementable positive integer.") + if (providerRevision > 2_147_483_646) { + throw new Error("HUB_OIDC_PROVIDER_REVISION must fit the incrementable database integer range.") } const clientId = requiredEnvironmentValue(environment, "HUB_OIDC_CLIENT_ID") if (clientId.length > 255) { 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 51773cce..0282bdcb 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -274,60 +274,67 @@ describe("createIdentityStore()", () => { it("applies the migration constraint for every current installation membership source", async () => { const { prisma, store } = await database() - await prisma.$executeRawUnsafe(` - ALTER TABLE "installation_memberships" - ADD CONSTRAINT "installation_memberships_source" - CHECK ("source" IN ('bootstrap', 'invitation', 'manual', 'recovery', 'synchronized')) - `) - const migration = await readFile( - new URL( - "../../../../schema/migrations/20261028210000_oidc_review_hardening/migration.sql", - import.meta.url, - ), - "utf8", - ) - const constraintSql = migration.slice( - migration.indexOf('ALTER TABLE "installation_memberships"'), - ) - await prisma.$executeRawUnsafe(constraintSql) - - for (const source of [ - "bootstrap", - "invitation", - "legacy-synchronized", - "manual", - "oidc-assignment", - "recovery", - "synchronized", - ] as const) { - const user = await store.lock((records) => - records.createUser({ displayName: `Source ${source}`, avatarUrl: null }), + try { + await prisma.$executeRawUnsafe(` + ALTER TABLE "installation_memberships" + ADD CONSTRAINT "installation_memberships_source" + CHECK ("source" IN ('bootstrap', 'invitation', 'manual', 'recovery', 'synchronized')) + `) + const migration = await readFile( + new URL( + "../../../../schema/migrations/20261028210000_oidc_review_hardening/migration.sql", + import.meta.url, + ), + "utf8", + ) + const constraintSql = migration.slice( + migration.indexOf('ALTER TABLE "installation_memberships"'), + ) + await prisma.$executeRawUnsafe(constraintSql) + + for (const source of [ + "bootstrap", + "invitation", + "legacy-synchronized", + "manual", + "oidc-assignment", + "recovery", + "synchronized", + ] as const) { + const user = await store.lock((records) => + records.createUser({ displayName: `Source ${source}`, avatarUrl: null }), + ) + await expect( + store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source, + }), + ), + ).resolves.toMatchObject({ source }) + } + + const unsupportedUser = await store.lock((records) => + records.createUser({ displayName: "Unsupported source", avatarUrl: null }), ) await expect( - store.lock((records) => - records.saveMembership({ - userId: user.id, + prisma.installationMembership.create({ + data: { + userId: unsupportedUser.id, status: "active", role: "member", - source, - }), - ), - ).resolves.toMatchObject({ source }) + source: "provider-default", + }, + }), + ).rejects.toThrow() + } finally { + await prisma.$executeRawUnsafe(` + ALTER TABLE "installation_memberships" + DROP CONSTRAINT IF EXISTS "installation_memberships_source" + `) } - - const unsupportedUser = await store.lock((records) => - records.createUser({ displayName: "Unsupported source", avatarUrl: null }), - ) - await expect( - prisma.installationMembership.create({ - data: { - userId: unsupportedUser.id, - status: "active", - role: "member", - source: "provider-default", - }, - }), - ).rejects.toThrow() }) it("stores OIDC provider revisions separately from GitHub integration credentials", async () => { diff --git a/hub/server/utils/normalize-entra-issuer/index.ts b/hub/server/utils/normalize-entra-issuer/index.ts new file mode 100644 index 00000000..b68b8fc0 --- /dev/null +++ b/hub/server/utils/normalize-entra-issuer/index.ts @@ -0,0 +1,8 @@ +const entraIssuerPattern = + /^https:\/\/login\.microsoftonline\.com\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/v2\.0\/?$/i + +export function normalizeEntraIssuer(value: string): string | null { + const match = value.match(entraIssuerPattern) + const tenantId = match?.[1] + return tenantId ? `https://login.microsoftonline.com/${tenantId.toLowerCase()}/v2.0` : null +} From bb704dcb6227eca294e734a91bd387aedcbefa28 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 18:38:41 -0700 Subject: [PATCH 48/56] fix(hub): defer OIDC revision persistence Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-runtime/index.test.ts | 33 +++++++++++ .../authentication/create-runtime/index.ts | 55 ++++++++++--------- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/hub/server/domains/authentication/create-runtime/index.test.ts b/hub/server/domains/authentication/create-runtime/index.test.ts index f3524c70..d13c236f 100644 --- a/hub/server/domains/authentication/create-runtime/index.test.ts +++ b/hub/server/domains/authentication/create-runtime/index.test.ts @@ -27,6 +27,24 @@ vi.mock("#server/integrations/prisma", () => ({ prisma: {} })) import { createAuthenticationRuntime } from "#server/domains/authentication/create-runtime" +function oidcEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + HUB_AUTH_PROVIDER: "oidc", + HUB_OIDC_ENABLED: "true", + HUB_OIDC_ISSUER: "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + HUB_OIDC_CLIENT_ID: "hub-client", + HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE: "HUB_OIDC_CLIENT_SECRET", + HUB_OIDC_CLIENT_SECRET: "client-secret-fixture", + HUB_OIDC_CALLBACK_URL: "http://127.0.0.1:5173/api/auth/oidc/callback", + HUB_OIDC_SCOPES: "openid profile email", + HUB_OIDC_MEMBER_ROLE: "Hub.User", + HUB_OIDC_ADMINISTRATOR_ROLE: "Hub.Administrator", + HUB_OIDC_MEMBERSHIP_POLICY: "invitation-required", + HUB_OIDC_PROVIDER_REVISION: "5", + ...overrides, + } +} + describe("createAuthenticationRuntime()", () => { it.each([undefined, "", " \t "])( "keeps unset artifact storage outside the checkout for %j", @@ -67,4 +85,19 @@ describe("createAuthenticationRuntime()", () => { expect(ensureOidcProviderRevision).not.toHaveBeenCalled() }) + + it("rejects invalid host secrets before persisting a replacement OIDC provider revision", async () => { + const sharedSecret = "shared-host-secret-fixture-value" + + await expect( + createAuthenticationRuntime( + oidcEnvironment({ + HUB_BOOTSTRAP_SECRET: sharedSecret, + HUB_ADMIN_RECOVERY_SECRET: sharedSecret, + }), + ), + ).rejects.toThrow("HUB_BOOTSTRAP_SECRET and HUB_ADMIN_RECOVERY_SECRET must be different.") + + expect(ensureOidcProviderRevision).not.toHaveBeenCalled() + }) }) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index f6fb5520..5094b90e 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -85,12 +85,6 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv environment, store, }) - const providerRevision = configuration - ? await ensureGitHubProviderRevision(store, configuration) - : null - const oidcProviderRevision = oidcConfiguration - ? await ensureOidcProviderRevision(store, oidcConfiguration) - : null const admissionPolicy = createAdmissionPolicy(environment, store, githubAuthorization) const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") @@ -109,31 +103,42 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv const configuredArtifactStoragePath = environment.HUB_ARTIFACT_STORAGE_PATH?.trim() const artifactStoragePath = configuredArtifactStoragePath || path.join(os.homedir(), ".replica", "artifacts") + const administration = createAdministrationService({ store }) + const artifactPublishing = createArtifactPublishingService({ + database: prisma, + storage: createLocalArtifactStorage(artifactStoragePath), + }) + const prototypeAuthorization = createPrototypeAuthorizationService({ + baseline: { + baseline: (database, userId, prototypeId) => + githubAuthorization.prototypeBaseline(database, userId, prototypeId), + workspaceIds: (database, userId) => + githubAuthorization.prototypeWorkspaceIds(database, userId), + prototypeIds: (database, userId, workspaceId) => + githubAuthorization.prototypeIds(database, userId, workspaceId), + }, + database: prisma, + store, + }) + const github = configuration ? createGitHubUserAuthClient(configuration) : null + const oidc = oidcConfiguration ? createOidcClient(oidcConfiguration) : null + const providerRevision = configuration + ? await ensureGitHubProviderRevision(store, configuration) + : null + const oidcProviderRevision = oidcConfiguration + ? await ensureOidcProviderRevision(store, oidcConfiguration) + : null return { - administration: createAdministrationService({ store }), - artifactPublishing: createArtifactPublishingService({ - database: prisma, - storage: createLocalArtifactStorage(artifactStoragePath), - }), + administration, + artifactPublishing, githubAuthorization, - prototypeAuthorization: createPrototypeAuthorizationService({ - baseline: { - baseline: (database, userId, prototypeId) => - githubAuthorization.prototypeBaseline(database, userId, prototypeId), - workspaceIds: (database, userId) => - githubAuthorization.prototypeWorkspaceIds(database, userId), - prototypeIds: (database, userId, workspaceId) => - githubAuthorization.prototypeIds(database, userId, workspaceId), - }, - database: prisma, - store, - }), + prototypeAuthorization, service: createAuthenticationService({ store, - github: configuration ? createGitHubUserAuthClient(configuration) : null, + github, configuration, providerRevisionId: providerRevision?.id ?? null, - oidc: oidcConfiguration ? createOidcClient(oidcConfiguration) : null, + oidc, oidcConfiguration, oidcProviderRevisionId: oidcProviderRevision?.id ?? null, provider, From 1f0e211fa573bc96e2920e7e3713781f6266c4ac Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 20:17:37 -0700 Subject: [PATCH 49/56] fix(hub): harden OIDC invitation roles Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.devcontainer/compose.yaml | 1 + hub/docs/entra-oidc.md | 2 +- .../complete-oidc-authorization/index.ts | 23 +++- .../create-service/oidc.test.ts | 128 +++++++++++++----- hub/test/devcontainer-config.test.ts | 40 ++++++ 5 files changed, 153 insertions(+), 41 deletions(-) create mode 100644 hub/test/devcontainer-config.test.ts diff --git a/hub/.devcontainer/compose.yaml b/hub/.devcontainer/compose.yaml index f95cc194..e2201e2a 100644 --- a/hub/.devcontainer/compose.yaml +++ b/hub/.devcontainer/compose.yaml @@ -11,6 +11,7 @@ services: environment: DATABASE_URL: postgresql://replica:replica@db:5432/replica TEST_DATABASE_URL: postgresql://replica:replica@db:5432/postgres + HUB_AUTH_PROVIDER: github depends_on: db: condition: service_healthy diff --git a/hub/docs/entra-oidc.md b/hub/docs/entra-oidc.md index 31088634..82e3b249 100644 --- a/hub/docs/entra-oidc.md +++ b/hub/docs/entra-oidc.md @@ -47,7 +47,7 @@ HUB_OIDC_PROVIDER_REVISION=1 The callback must exactly match `HUB_ORIGIN` plus `/api/auth/oidc/callback`. The first supported profile requires the issuer `https://login.microsoftonline.com//v2.0`; Hub does not accept a generic hostname or tenant alias. Discovered authorization, token, and JWKS endpoints must use that same HTTPS origin. Increase `HUB_OIDC_PROVIDER_REVISION` whenever any OIDC setting or referenced credential changes. Hub rejects revision rollback and same-revision configuration drift. -`jit-assignment` creates an `oidc-assignment` membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. Those provider-owned memberships continue to track the current mapped app role. `invitation-required` also requires a matching Hub invitation. For Entra invitations, enter the user's tenant directory object ID (`oid`), which an administrator can copy from the user's **Object ID** in the Entra admin center or read through approved Microsoft Graph directory tooling. Hub keeps the application-specific OIDC `sub` as the external sign-in identity and uses the tenant-scoped `oid` only to match the pre-created invitation. Invitation, bootstrap, recovery, and manually changed memberships preserve their Hub-owned role instead of being overwritten by later app-role claims. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. Hub refuses a claim-driven demotion of an `oidc-assignment` membership that would remove the final active administrator. +`jit-assignment` creates an `oidc-assignment` membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. Those provider-owned memberships continue to track the current mapped app role. `invitation-required` also requires a matching Hub invitation. For Entra invitations, enter the user's tenant directory object ID (`oid`), which an administrator can copy from the user's **Object ID** in the Entra admin center or read through approved Microsoft Graph directory tooling. Hub keeps the application-specific OIDC `sub` as the external sign-in identity and uses the tenant-scoped `oid` only to match the pre-created invitation. An administrator invitation is not consumed until the provider also grants `Hub.Administrator`; Hub never silently activates it as a member. Invitation, bootstrap, recovery, and manually changed memberships preserve their Hub-owned role instead of being overwritten by later app-role claims. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. Hub refuses a claim-driven demotion of an `oidc-assignment` membership that would remove the final active administrator. ## Identity and lifecycle boundaries diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts index 8337018a..c8a93164 100644 --- a/hub/server/domains/authentication/complete-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -185,6 +185,23 @@ export async function completeOidcAuthorization( }) return { denied: true as const } } + if ( + purpose === "sign-in" && + invitation?.role === "administrator" && + profile.role !== "administrator" + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership?.id ?? null, + invitationId: invitation.id, + context: { reason: "administrator_role_required" }, + }) + return { denied: true as const } + } if (purpose === "bootstrap") { const completed = (await records.activeAdministratorCount()) === 0 && (await records.completeBootstrap(now())) @@ -262,14 +279,10 @@ export async function completeOidcAuthorization( targetMembershipId: membership.id, }) } else if (invitation && membership?.status !== "suspended") { - const invitedRole = - invitation.role === "administrator" && profile.role === "administrator" - ? "administrator" - : "member" membership = await records.saveMembership({ userId: identity.userId, status: "active", - role: invitedRole, + role: invitation.role, source: "invitation", suspendedAt: null, removedAt: null, diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index 34aab419..f64c324f 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -218,6 +218,23 @@ function existingIdentity() { } } +function invitation(role: "member" | "administrator" = "member") { + return { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "oidc", + issuer, + subject: "a0e1d2c3-b4a5-4678-9abc-def012345678", + role, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } +} + describe("createAuthenticationService() OIDC", () => { it("starts a browser-bound attempt with one-use state, nonce, PKCE, and a ten-minute lifetime", async () => { const identityRecords = records() @@ -579,10 +596,16 @@ describe("createAuthenticationService() OIDC", () => { expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) }) - it.each(["invitation", "manual", "bootstrap", "recovery"] as const)( - "preserves a %s role override when the provider still grants an admitted role", - async (source) => { - const membership = activeMembership("administrator", source) + it.each([ + ["invitation", "administrator", "member"], + ["invitation", "member", "administrator"], + ["manual", "administrator", "member"], + ["bootstrap", "administrator", "member"], + ["recovery", "administrator", "member"], + ] as const)( + "preserves a %s %s role override when the provider grants %s", + async (source, membershipRole, providerRole) => { + const membership = activeMembership(membershipRole, source) const identityRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), @@ -590,7 +613,7 @@ describe("createAuthenticationService() OIDC", () => { }) const { authentication } = service( identityRecords, - oidc({ identify: vi.fn().mockResolvedValue(profile({ role: "member" })) }), + oidc({ identify: vi.fn().mockResolvedValue(profile({ role: providerRole })) }), ) await expect( @@ -754,25 +777,11 @@ describe("createAuthenticationService() OIDC", () => { service(deniedRecords).authentication.complete("code", "state", "browser-token"), ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) - const invitation = { - id: "invitation", - createdByUserId: "administrator", - activatedIdentityId: null, - activatedMembershipId: null, - provider: "oidc", - issuer, - subject: "a0e1d2c3-b4a5-4678-9abc-def012345678", - role: "member" as const, - expiresAt: new Date(now.getTime() + 60_000), - consumedAt: null, - revokedAt: null, - createdAt: now, - } const invitedRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), externalIdentity: vi.fn().mockResolvedValue(identity), membership: vi.fn().mockResolvedValue(removed), - usableInvitation: vi.fn().mockResolvedValue(invitation), + usableInvitation: vi.fn().mockResolvedValue(invitation()), }) const invitedResult = await service(invitedRecords).authentication.complete( @@ -803,24 +812,73 @@ describe("createAuthenticationService() OIDC", () => { ) }) + it("keeps an administrator invitation available when the provider grants only member", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation("administrator")), + }) + + await expect( + service(identityRecords, oidc(), { + membershipPolicy: "invitation-required", + }).authentication.complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) + + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.consumeInvitation).not.toHaveBeenCalled() + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: null, + invitationId: "invitation", + context: { reason: "administrator_role_required" }, + }) + }) + + it.each([ + ["member", "member"], + ["member", "administrator"], + ["administrator", "administrator"], + ] as const)( + "activates a %s invitation with a %s provider role without changing invitation authority", + async (invitationRole, providerRole) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation(invitationRole)), + }) + const client = oidc({ + identify: vi.fn().mockResolvedValue(profile({ role: providerRole })), + }) + + await service(identityRecords, client, { + membershipPolicy: "invitation-required", + }).authentication.complete("code", "state", "browser-token") + + expect(identityRecords.saveMembership).toHaveBeenCalledWith({ + userId: "user", + status: "active", + role: invitationRole, + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + }, + ) + it("activates a pre-sign-in invitation by directory object ID while retaining the OIDC subject", async () => { - const invitation = { - id: "invitation", - createdByUserId: "administrator", - activatedIdentityId: null, - activatedMembershipId: null, - provider: "oidc", - issuer, - subject: "a0e1d2c3-b4a5-4678-9abc-def012345678", - role: "member" as const, - expiresAt: new Date(now.getTime() + 60_000), - consumedAt: null, - revokedAt: null, - createdAt: now, - } const identityRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), - usableInvitation: vi.fn().mockResolvedValue(invitation), + usableInvitation: vi.fn().mockResolvedValue(invitation()), }) await service(identityRecords, oidc(), { diff --git a/hub/test/devcontainer-config.test.ts b/hub/test/devcontainer-config.test.ts new file mode 100644 index 00000000..3e6d723d --- /dev/null +++ b/hub/test/devcontainer-config.test.ts @@ -0,0 +1,40 @@ +import { readFile } from "node:fs/promises" + +import { describe, expect, it } from "vitest" + +function mappingBlock(source: string, key: string, indentation: number) { + const lines = source.split("\n") + const start = lines.findIndex((line) => line === `${" ".repeat(indentation)}${key}:`) + const block: string[] = [] + + if (start < 0) return "" + + for (const line of lines.slice(start + 1)) { + if (line.trim() !== "" && line.search(/\S/) <= indentation) break + block.push(line) + } + + return block.join("\n") +} + +describe("Hub devcontainer", () => { + it("selects GitHub authentication explicitly for local startup", async () => { + const compose = await readFile( + new URL("../.devcontainer/compose.yaml", import.meta.url), + "utf8", + ) + const appService = mappingBlock(compose, "app", 2) + const environment = mappingBlock(appService, "environment", 4) + const values = new Map( + environment + .trim() + .split("\n") + .map((line): [string, string] => { + const [key = "", value = ""] = line.trim().split(/:\s+/, 2) + return [key, value] + }), + ) + + expect(values.get("HUB_AUTH_PROVIDER")).toBe("github") + }) +}) From 7f2840ac12440a39992acdd6507b92ae97f91022 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 21:42:05 -0700 Subject: [PATCH 50/56] fix(hub): allow devcontainer OIDC override Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.devcontainer/compose.yaml | 5 ++++- hub/.devcontainer/default.env | 1 + hub/.prettierignore | 1 + hub/docs/development.md | 2 +- hub/test/devcontainer-config.test.ts | 22 ++++++++++------------ 5 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 hub/.devcontainer/default.env diff --git a/hub/.devcontainer/compose.yaml b/hub/.devcontainer/compose.yaml index e2201e2a..230ade1e 100644 --- a/hub/.devcontainer/compose.yaml +++ b/hub/.devcontainer/compose.yaml @@ -8,10 +8,13 @@ services: working_dir: /workspaces/replica/hub volumes: - ../..:/workspaces/replica:cached + env_file: + - path: default.env + - path: ../.env + required: false environment: DATABASE_URL: postgresql://replica:replica@db:5432/replica TEST_DATABASE_URL: postgresql://replica:replica@db:5432/postgres - HUB_AUTH_PROVIDER: github depends_on: db: condition: service_healthy diff --git a/hub/.devcontainer/default.env b/hub/.devcontainer/default.env new file mode 100644 index 00000000..f4130150 --- /dev/null +++ b/hub/.devcontainer/default.env @@ -0,0 +1 @@ +HUB_AUTH_PROVIDER=github diff --git a/hub/.prettierignore b/hub/.prettierignore index 5c018a01..bb7de867 100644 --- a/hub/.prettierignore +++ b/hub/.prettierignore @@ -1,6 +1,7 @@ /log .local .npm +.devcontainer/default.env .storybook/instances .storybook/settings.json node_modules diff --git a/hub/docs/development.md b/hub/docs/development.md index 571dd442..dedc7e28 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -20,7 +20,7 @@ cp .env.example .env npm ci --engine-strict ``` -The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. Set `HUB_AUTH_PROVIDER=github` with `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in, or select and configure the complete OIDC profile in [Microsoft Entra OIDC](entra-oidc.md). Keep `HUB_AUTH_PROVIDER=github` without GitHub credentials to run Hub with sign-in disabled. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. +The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. The devcontainer loads its committed `github` provider default first and then the optional `hub/.env`, so the local file can select OIDC without a Compose edit. Set `HUB_AUTH_PROVIDER=github` with `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in, or select and configure the complete OIDC profile in [Microsoft Entra OIDC](entra-oidc.md). Keep `HUB_AUTH_PROVIDER=github` without GitHub credentials to run Hub with sign-in disabled. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. ## Start the app diff --git a/hub/test/devcontainer-config.test.ts b/hub/test/devcontainer-config.test.ts index 3e6d723d..4c821722 100644 --- a/hub/test/devcontainer-config.test.ts +++ b/hub/test/devcontainer-config.test.ts @@ -18,23 +18,21 @@ function mappingBlock(source: string, key: string, indentation: number) { } describe("Hub devcontainer", () => { - it("selects GitHub authentication explicitly for local startup", async () => { + it("defaults to GitHub while allowing hub/.env to select OIDC", async () => { const compose = await readFile( new URL("../.devcontainer/compose.yaml", import.meta.url), "utf8", ) - const appService = mappingBlock(compose, "app", 2) - const environment = mappingBlock(appService, "environment", 4) - const values = new Map( - environment - .trim() - .split("\n") - .map((line): [string, string] => { - const [key = "", value = ""] = line.trim().split(/:\s+/, 2) - return [key, value] - }), + const defaults = await readFile( + new URL("../.devcontainer/default.env", import.meta.url), + "utf8", ) + const appService = mappingBlock(compose, "app", 2) + const environmentFiles = mappingBlock(appService, "env_file", 4) - expect(values.get("HUB_AUTH_PROVIDER")).toBe("github") + expect(defaults.trim()).toBe("HUB_AUTH_PROVIDER=github") + expect(environmentFiles).toMatch(/- path: default\.env[\s\S]*- path: \.\.\/\.env/) + expect(environmentFiles).toMatch(/path: \.\.\/\.env[\s\S]*required: false/) + expect(mappingBlock(appService, "environment", 4)).not.toContain("HUB_AUTH_PROVIDER") }) }) From 55620fb97afab70bd5e00661ba8e90fe64f28a75 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 02:52:14 -0700 Subject: [PATCH 51/56] fix(hub): preserve Entra identity continuity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/entra-oidc.md | 4 ++ .../complete-oidc-authorization/index.ts | 10 ++- .../create-service/index.db.test.ts | 62 +++++++++++++------ .../create-service/oidc.test.ts | 33 ++++++---- .../domains/identity/create-records/index.ts | 2 + .../domains/identity/create-store/types.ts | 4 ++ .../update-external-identity-subject/index.ts | 13 ++++ 7 files changed, 91 insertions(+), 37 deletions(-) create mode 100644 hub/server/repositories/external-identities/update-external-identity-subject/index.ts diff --git a/hub/docs/entra-oidc.md b/hub/docs/entra-oidc.md index 82e3b249..5ca012eb 100644 --- a/hub/docs/entra-oidc.md +++ b/hub/docs/entra-oidc.md @@ -47,6 +47,8 @@ HUB_OIDC_PROVIDER_REVISION=1 The callback must exactly match `HUB_ORIGIN` plus `/api/auth/oidc/callback`. The first supported profile requires the issuer `https://login.microsoftonline.com//v2.0`; Hub does not accept a generic hostname or tenant alias. Discovered authorization, token, and JWKS endpoints must use that same HTTPS origin. Increase `HUB_OIDC_PROVIDER_REVISION` whenever any OIDC setting or referenced credential changes. Hub rejects revision rollback and same-revision configuration drift. +Prefer rotating the client credential without replacing the Entra application client ID. If an operator intentionally replaces the application in the same tenant, Entra changes the application-specific `sub`; Hub uses the signed tenant `oid` to migrate the existing external identity to the new subject and records that migration. This continuity does not link providers or tenants. + `jit-assignment` creates an `oidc-assignment` membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. Those provider-owned memberships continue to track the current mapped app role. `invitation-required` also requires a matching Hub invitation. For Entra invitations, enter the user's tenant directory object ID (`oid`), which an administrator can copy from the user's **Object ID** in the Entra admin center or read through approved Microsoft Graph directory tooling. Hub keeps the application-specific OIDC `sub` as the external sign-in identity and uses the tenant-scoped `oid` only to match the pre-created invitation. An administrator invitation is not consumed until the provider also grants `Hub.Administrator`; Hub never silently activates it as a member. Invitation, bootstrap, recovery, and manually changed memberships preserve their Hub-owned role instead of being overwritten by later app-role claims. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. Hub refuses a claim-driven demotion of an `oidc-assignment` membership that would remove the final active administrator. ## Identity and lifecycle boundaries @@ -55,4 +57,6 @@ OIDC proves the immutable tuple `provider + issuer/tenant + subject`. Preferred JIT provisioning occurs only during a successful sign-in and creates or refreshes Hub membership under the configured admission policy. It is not SCIM: Hub does not synchronize users, groups, suspension, or removal in the background. Entra assignment and app roles control installation admission and administrator mapping; they do not grant future prototype, repository, workspace, artifact, or review permissions. +Keep at least one group assigned to `Hub.Administrator`. Administrator recovery still requires both the host-held recovery secret and a signed `Hub.Administrator` app-role claim; it does not bypass a missing tenant assignment. + Hub sign-out revokes the opaque Hub session. It does not assume or initiate Entra single sign-out. Provider logout can be added later as a separately configured deployment behavior. diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts index c8a93164..31fa145e 100644 --- a/hub/server/domains/authentication/complete-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -141,17 +141,15 @@ export async function completeOidcAuthorization( ) : null if (!identity && directoryIdentity) { - const directoryMembership = await records.membership(directoryIdentity.userId) + identity = await records.updateExternalIdentitySubject(directoryIdentity.id, profile.subject) await records.audit({ - event: "authentication.admission_denied", - outcome: "denied", + event: "external_identity.subject_migrate", + outcome: "succeeded", actorUserId: directoryIdentity.userId, sessionId: null, authenticationProviderRevisionId: oidcProviderRevisionId, - targetMembershipId: directoryMembership?.id ?? null, - context: { reason: "provider_subject_changed" }, + context: { source: "directory_object_id" }, }) - return { denied: true as const } } let membership = identity ? await records.membership(identity.userId) : null const invitationSubject = profile.directoryObjectId diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 5d7f3c36..1dc5f4c7 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -514,7 +514,7 @@ describe("createAuthenticationService() database behavior", () => { ]) }) - it("denies a changed provider subject without violating the directory object ID uniqueness", async () => { + it("migrates a changed provider subject without duplicating the existing identity graph", async () => { const { prisma, store } = await database() const graph = await createOidcMember(store, "member") await store.lock((records) => @@ -530,25 +530,47 @@ describe("createAuthenticationService() database behavior", () => { }), ) - await expect( - oidcService(store, graph.provider.id, "member", "replacement-subject").complete( - "provider-code", - "changed-subject-state", - "browser-token", - ), - ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) - - expect(await prisma.externalIdentity.count({ where: { userId: graph.user.id } })).toBe(1) - expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(0) - expect(await store.read.audits()).toEqual([ - expect.objectContaining({ - event: "authentication.admission_denied", - outcome: "denied", - actorUserId: graph.user.id, - targetMembershipId: graph.membership.id, - context: { reason: "provider_subject_changed" }, - }), - ]) + const result = await oidcService( + store, + graph.provider.id, + "member", + "replacement-subject", + ).complete("provider-code", "changed-subject-state", "browser-token") + + expect(result.purpose).toBe("sign-in") + expect(result.sessionToken).toEqual(expect.any(String)) + expect(await prisma.user.count()).toBe(1) + expect(await prisma.externalIdentity.count()).toBe(1) + expect(await prisma.installationMembership.count()).toBe(1) + expect( + await prisma.externalIdentity.findUnique({ where: { id: graph.identity.id } }), + ).toMatchObject({ + userId: graph.user.id, + subject: "replacement-subject", + directoryObjectId: graph.identity.directoryObjectId, + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: graph.membership.id } }), + ).toMatchObject({ userId: graph.user.id, status: "active", role: "member" }) + expect(await prisma.session.findFirst({ where: { userId: graph.user.id } })).toMatchObject({ + externalIdentityId: graph.identity.id, + authenticationProviderRevisionId: graph.provider.id, + }) + expect(await store.read.audits()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "external_identity.subject_migrate", + outcome: "succeeded", + actorUserId: graph.user.id, + context: { source: "directory_object_id" }, + }), + expect.objectContaining({ + event: "session.sign_in", + outcome: "succeeded", + actorUserId: graph.user.id, + }), + ]), + ) }) it("commits a provider role refresh and its supported audit context together", async () => { diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index f64c324f..dfe863d5 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -52,6 +52,7 @@ function records(overrides: Partial = {}) { consumeAuthorizationAttempt: vi.fn().mockResolvedValue(null), externalIdentity: vi.fn().mockResolvedValue(null), externalIdentityByDirectoryObjectId: vi.fn().mockResolvedValue(null), + updateExternalIdentitySubject: vi.fn(), createUser: vi.fn().mockResolvedValue({ id: "user", displayName: "Member Name", @@ -492,12 +493,14 @@ describe("createAuthenticationService() OIDC", () => { }) }) - it("denies a changed provider subject that reuses an existing directory object ID", async () => { + it("migrates a changed subject through the signed directory object ID and retains the member", async () => { const identity = existingIdentity() const membership = activeMembership() + const migratedIdentity = { ...identity, subject: "replacement-subject" } const identityRecords = records({ consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), externalIdentityByDirectoryObjectId: vi.fn().mockResolvedValue(identity), + updateExternalIdentitySubject: vi.fn().mockResolvedValue(migratedIdentity), membership: vi.fn().mockResolvedValue(membership), }) const client = oidc({ @@ -505,23 +508,31 @@ describe("createAuthenticationService() OIDC", () => { }) const { authentication } = service(identityRecords, client) - await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ - status: 403, - code: "admission_denied", - }) + const result = await authentication.complete("code", "state", "browser-token") + expect(result.purpose).toBe("sign-in") + expect(result.sessionToken).toEqual(expect.any(String)) + expect(identityRecords.updateExternalIdentitySubject).toHaveBeenCalledWith( + identity.id, + "replacement-subject", + ) expect(identityRecords.audit).toHaveBeenCalledWith({ - event: "authentication.admission_denied", - outcome: "denied", + event: "external_identity.subject_migrate", + outcome: "succeeded", actorUserId: "user", sessionId: null, authenticationProviderRevisionId: providerRevision.id, - targetMembershipId: membership.id, - context: { reason: "provider_subject_changed" }, + context: { source: "directory_object_id" }, }) expect(identityRecords.createUser).not.toHaveBeenCalled() - expect(identityRecords.saveExternalIdentity).not.toHaveBeenCalled() - expect(identityRecords.createSession).not.toHaveBeenCalled() + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: identity.userId, + externalIdentityId: identity.id, + authenticationProviderRevisionId: providerRevision.id, + }), + ) }) it("refreshes authorization only when the active membership still matches the signed provider role", async () => { diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index f0bbe9ba..71087393 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -7,6 +7,7 @@ import { createAuthorizationAttempt } from "#server/repositories/authorization-a import { getExternalIdentity } from "#server/repositories/external-identities/get-external-identity" import { getExternalIdentityByDirectoryObjectId } from "#server/repositories/external-identities/get-external-identity-by-directory-object-id" import { saveExternalIdentity } from "#server/repositories/external-identities/save-external-identity" +import { updateExternalIdentitySubject } from "#server/repositories/external-identities/update-external-identity-subject" import { advanceGitHubIntegrationGeneration } from "#server/repositories/github-integration-state/advance-generation" import { hasFreshGitHubAdmission } from "#server/repositories/github-synchronized-access/has-fresh-admission" import { getGitHubIntegrationState } from "#server/repositories/github-integration-state/get-state" @@ -63,6 +64,7 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident database, ), saveExternalIdentity: saveExternalIdentity.bind(null, database), + updateExternalIdentitySubject: updateExternalIdentitySubject.bind(null, database), membership: getInstallationMembership.bind(null, database), membershipById: getInstallationMembershipById.bind(null, database), memberships: getInstallationMemberships.bind(null, database), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 6843f674..435c9f5b 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -65,6 +65,10 @@ export interface IdentityRecords { saveExternalIdentity( input: ExternalIdentityInput, ): Promise + updateExternalIdentitySubject( + id: string, + subject: string, + ): Promise membership(userId: string): Promise membershipById(id: string): Promise memberships(): Promise diff --git a/hub/server/repositories/external-identities/update-external-identity-subject/index.ts b/hub/server/repositories/external-identities/update-external-identity-subject/index.ts new file mode 100644 index 00000000..7dbc57e1 --- /dev/null +++ b/hub/server/repositories/external-identities/update-external-identity-subject/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function updateExternalIdentitySubject( + database: Prisma.TransactionClient, + id: string, + subject: string, +) { + return database.externalIdentity.update({ + where: { id }, + data: { subject }, + include: { user: true }, + }) +} From e127a30039508dc46ebc11b8429e39ed3efcee82 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 04:07:14 -0700 Subject: [PATCH 52/56] fix(hub): require fresh OIDC verification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/identity.md | 2 +- .../migration.sql | 1 - .../migration.sql | 4 +- .../complete-oidc-authorization/index.ts | 7 ++- .../create-service/oidc.test.ts | 37 ++++++++++++++ .../start-oidc-authorization/index.ts | 5 +- .../identity/create-store/index.db.test.ts | 25 ++++++++- .../oidc/create-client/index.test.ts | 51 +++++++++++++++++++ .../integrations/oidc/create-client/index.ts | 21 +++++++- .../integrations/oidc/create-client/types.ts | 14 ++++- 10 files changed, 157 insertions(+), 10 deletions(-) delete mode 100644 hub/schema/migrations/20261027221500_oidc_identity_attributes/migration.sql diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 8e7fd7f3..e63cbbed 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -28,7 +28,7 @@ Hub sessions use a random opaque cookie and store only its digest. Authenticatio Artifact publishing uses a separate workload identity boundary. Prototype administrators issue prototype-scoped credentials with `publish` and/or `read-status`; Hub returns the raw bearer token once and stores only its digest. Workload credentials never create browser sessions or inherit user, installation, repository, or source-provider access. See [Artifact ingestion](artifact-ingestion.md). -Sign-out revokes the current database session. Provider logout is optional and is not assumed. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses the configured provider and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. +Sign-out revokes the current database session. Provider logout is optional and is not assumed. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh OIDC verification requests `prompt=login` and `max_age=0`, requires a current `auth_time`, and must return the same external identity before administrator mutations. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. ## Persisted boundaries diff --git a/hub/schema/migrations/20261027221500_oidc_identity_attributes/migration.sql b/hub/schema/migrations/20261027221500_oidc_identity_attributes/migration.sql deleted file mode 100644 index bd255190..00000000 --- a/hub/schema/migrations/20261027221500_oidc_identity_attributes/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "external_identities" ADD COLUMN "email" TEXT; diff --git a/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql b/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql index 1865f13f..164ff676 100644 --- a/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql +++ b/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql @@ -1,4 +1,6 @@ -ALTER TABLE "external_identities" ADD COLUMN "directory_object_id" TEXT; +ALTER TABLE "external_identities" +ADD COLUMN "email" TEXT, +ADD COLUMN "directory_object_id" TEXT; CREATE UNIQUE INDEX "external_identities_provider_issuer_directory_object_id_key" ON "external_identities"("provider", "issuer", "directory_object_id"); diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts index 31fa145e..bd73cc0a 100644 --- a/hub/server/domains/authentication/complete-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -49,7 +49,12 @@ export async function completeOidcAuthorization( const purpose: AuthorizationPurpose = attempt.purpose let profile try { - profile = await oidc.identify(code, attempt.pkceVerifier, attempt.nonce) + profile = + purpose === "verify" + ? await oidc.identify(code, attempt.pkceVerifier, attempt.nonce, { + maximumAuthenticationAgeSeconds: 0, + }) + : await oidc.identify(code, attempt.pkceVerifier, attempt.nonce) } catch { await store.lock((records) => records.audit({ diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index dfe863d5..fb94b0f9 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -265,6 +265,43 @@ describe("createAuthenticationService() OIDC", () => { }) }) + it("forwards fresh-authentication options only for session verification", async () => { + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: digestSecret("session-token"), + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "Member Name", avatarUrl: null }, + externalIdentity: existingIdentity(), + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + session: vi.fn().mockResolvedValue(session), + sessionById: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue(activeMembership()), + consumeAuthorizationAttempt: vi + .fn() + .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + }) + const { authentication, client } = service(identityRecords) + + await authentication.start("verify", "session-token") + await authentication.complete("code", "state", "browser-token") + + expect(client.authorizationUrl).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + { fresh: true }, + ) + expect(client.identify).toHaveBeenCalledWith("code", "pkce-verifier", "one-use-nonce", { + maximumAuthenticationAgeSeconds: 0, + }) + }) + it("consumes the saved attempt and audits a provider failure when initiation discovery fails", async () => { const identityRecords = records() const client = oidc({ diff --git a/hub/server/domains/authentication/start-oidc-authorization/index.ts b/hub/server/domains/authentication/start-oidc-authorization/index.ts index a57c9ef4..912617bd 100644 --- a/hub/server/domains/authentication/start-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/start-oidc-authorization/index.ts @@ -109,7 +109,10 @@ export async function startOidcAuthorization( if (denial) throw denial let authorizationUrl: string try { - authorizationUrl = await oidc.authorizationUrl(state, pkceVerifier, nonce) + authorizationUrl = + purpose === "verify" + ? await oidc.authorizationUrl(state, pkceVerifier, nonce, { fresh: true }) + : await oidc.authorizationUrl(state, pkceVerifier, nonce) } catch { await store.lock(async (records) => { await records.consumeAuthorizationAttempt(digestSecret(state)) 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 0282bdcb..013304ee 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises" +import { readdir, readFile } from "node:fs/promises" import { describe, expect, it, onTestFinished, vi } from "vitest" @@ -337,6 +337,29 @@ describe("createIdentityStore()", () => { } }) + it("introduces OIDC identity columns only in the final hardening migration", async () => { + const migrationsUrl = new URL("../../../../schema/migrations/", import.meta.url) + const migrationDirectories = (await readdir(migrationsUrl, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + const migrationNames = [] + const migrationsWithOidcIdentityColumns = [] + + for (const migrationName of migrationDirectories) { + const migrationUrl = new URL(`${migrationName}/`, migrationsUrl) + if (!(await readdir(migrationUrl)).includes("migration.sql")) continue + migrationNames.push(migrationName) + const sql = await readFile(new URL("migration.sql", migrationUrl), "utf8") + if (sql.includes('ADD COLUMN "email"') || sql.includes('ADD COLUMN "directory_object_id"')) { + migrationsWithOidcIdentityColumns.push(migrationName) + } + } + migrationNames.sort() + + expect(migrationsWithOidcIdentityColumns).toEqual(["20261028210000_oidc_review_hardening"]) + expect(migrationNames).not.toContain("20261027221500_oidc_identity_attributes") + }) + it("stores OIDC provider revisions separately from GitHub integration credentials", async () => { const { prisma, store } = await database() const revision = await store.lock((records) => diff --git a/hub/server/integrations/oidc/create-client/index.test.ts b/hub/server/integrations/oidc/create-client/index.test.ts index 3f635a4b..5fc0f08b 100644 --- a/hub/server/integrations/oidc/create-client/index.test.ts +++ b/hub/server/integrations/oidc/create-client/index.test.ts @@ -155,6 +155,19 @@ describe("createOidcClient()", () => { }) }) + it("requests a new provider login for fresh authorization", async () => { + const client = createOidcClient(configuration, { fetch: providerFetch({}) }) + + const url = new URL( + await client.authorizationUrl("opaque-state", "pkce-verifier", "one-use-nonce", { + fresh: true, + }), + ) + + expect(url.searchParams.get("prompt")).toBe("login") + expect(url.searchParams.get("max_age")).toBe("0") + }) + it("exchanges the code with the verifier and maps administrator claims without returning tokens", async () => { const key = await signingKey() const token = await idToken(key, { @@ -197,6 +210,44 @@ describe("createOidcClient()", () => { expect(JSON.stringify(profile)).not.toContain(token) }) + it("accepts fresh identification at the authentication-time tolerance boundary", async () => { + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(new Date("2026-10-29T10:00:00.000Z")) + const key = await signingKey() + const token = await idToken(key, { + auth_time: Math.floor(Date.now() / 1_000) - 5, + }) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [key.jwk] }), + }) + + await expect( + client.identify("code", "verifier", "expected-nonce", { + maximumAuthenticationAgeSeconds: 0, + }), + ).resolves.toMatchObject({ subject: "immutable-subject" }) + }) + + it.each([ + ["missing", undefined], + ["stale", Math.floor(new Date("2026-10-29T10:00:00.000Z").getTime() / 1_000) - 6], + ["materially future", Math.floor(new Date("2026-10-29T10:00:00.000Z").getTime() / 1_000) + 6], + ])("rejects fresh identification with a %s authentication time", async (_name, authTime) => { + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(new Date("2026-10-29T10:00:00.000Z")) + const key = await signingKey() + const token = await idToken(key, { auth_time: authTime }) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [key.jwk] }), + }) + + await expect( + client.identify("code", "verifier", "expected-nonce", { + maximumAuthenticationAgeSeconds: 0, + }), + ).rejects.toThrow("OIDC authentication is not fresh.") + }) + it.each([ ["nonce", { nonce: "different-nonce" }], ["audience", { aud: "different-client" }], diff --git a/hub/server/integrations/oidc/create-client/index.ts b/hub/server/integrations/oidc/create-client/index.ts index c34a1079..0ff56a4d 100644 --- a/hub/server/integrations/oidc/create-client/index.ts +++ b/hub/server/integrations/oidc/create-client/index.ts @@ -132,7 +132,7 @@ export function createOidcClient( } return { - async authorizationUrl(state, pkceVerifier, nonce) { + async authorizationUrl(state, pkceVerifier, nonce, options) { const metadata = await discovery() const url = new URL(metadata.authorizationEndpoint) url.search = new URLSearchParams({ @@ -146,9 +146,13 @@ export function createOidcClient( code_challenge: codeChallenge(pkceVerifier), code_challenge_method: "S256", }).toString() + if (options?.fresh) { + url.searchParams.set("prompt", "login") + url.searchParams.set("max_age", "0") + } return url.href }, - async identify(code, pkceVerifier, nonce): Promise { + async identify(code, pkceVerifier, nonce, options): Promise { const metadata = await discovery() const token = tokenSchema.parse( await fetchJson(metadata.tokenEndpoint, { @@ -183,6 +187,19 @@ export function createOidcClient( ) { throw new Error("OIDC token authorized party is invalid.") } + if (options?.maximumAuthenticationAgeSeconds !== undefined) { + const authenticationAge = + typeof claims.auth_time === "number" + ? Math.floor(Date.now() / 1_000) - claims.auth_time + : null + if ( + authenticationAge === null || + authenticationAge < -5 || + authenticationAge > options.maximumAuthenticationAgeSeconds + 5 + ) { + throw new Error("OIDC authentication is not fresh.") + } + } const roles = Array.isArray(claims.roles) ? claims.roles.filter((role): role is string => typeof role === "string") : [] diff --git a/hub/server/integrations/oidc/create-client/types.ts b/hub/server/integrations/oidc/create-client/types.ts index 38b38e24..eb5401f2 100644 --- a/hub/server/integrations/oidc/create-client/types.ts +++ b/hub/server/integrations/oidc/create-client/types.ts @@ -23,8 +23,18 @@ export interface OidcProfile { } export interface OidcClient { - authorizationUrl(state: string, pkceVerifier: string, nonce: string): Promise - identify(code: string, pkceVerifier: string, nonce: string): Promise + authorizationUrl( + state: string, + pkceVerifier: string, + nonce: string, + options?: { fresh?: boolean }, + ): Promise + identify( + code: string, + pkceVerifier: string, + nonce: string, + options?: { maximumAuthenticationAgeSeconds?: number }, + ): Promise } export interface OidcClientOptions { From 31174e3a0be53e44abdde461219c3b67ceb5d9fe Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 07:04:52 -0700 Subject: [PATCH 53/56] fix(hub): validate Entra authentication time Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/entra-oidc.md | 4 +- hub/docs/identity.md | 2 +- hub/script/setup-entra-oidc.ps1 | 48 +++++++++++++++++++ hub/script/tests/setup-entra-oidc.test.mjs | 47 ++++++++++++++++++ .../complete-oidc-authorization/index.ts | 2 +- .../create-service/oidc.test.ts | 11 +++-- .../oidc/create-client/index.test.ts | 25 +++++++--- .../integrations/oidc/create-client/index.ts | 14 +++--- .../integrations/oidc/create-client/types.ts | 2 +- 9 files changed, 132 insertions(+), 23 deletions(-) diff --git a/hub/docs/entra-oidc.md b/hub/docs/entra-oidc.md index 5ca012eb..f8a7526e 100644 --- a/hub/docs/entra-oidc.md +++ b/hub/docs/entra-oidc.md @@ -22,7 +22,7 @@ Install the listed Microsoft Graph PowerShell modules, sign in with the required -AdministratorGroupId ``` -Use `-Apply` with the same arguments to converge the application, service principal, assignment requirement, callback, `Hub.User` and `Hub.Administrator` app roles, and group assignments. Apply adds missing requested groups and removes obsolete group assignments for those two managed roles; unrelated app roles and their assignments are preserved. Use `-ExistingApplicationId ` to converge an existing application without claiming removal ownership. `-Remove` deletes only resources tagged as created by this script and refuses unowned resources. +Use `-Apply` with the same arguments to converge the application, service principal, assignment requirement, callback, required `auth_time` ID-token claim, `Hub.User` and `Hub.Administrator` app roles, and group assignments. Apply adds missing requested groups and removes obsolete group assignments for those two managed roles; unrelated optional claims, app roles, and assignments are preserved. Use `-ExistingApplicationId ` to converge an existing application without claiming removal ownership. `-Remove` deletes only resources tagged as created by this script and refuses unowned resources. The script prints nonsecret Hub configuration. It does not create or print a client credential. Create the credential through the tenant's approved process, place it in the secret store exposed by `HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE`, and protect it separately from the database. @@ -59,4 +59,6 @@ JIT provisioning occurs only during a successful sign-in and creates or refreshe Keep at least one group assigned to `Hub.Administrator`. Administrator recovery still requires both the host-held recovery secret and a signed `Hub.Administrator` app-role claim; it does not bypass a missing tenant assignment. +Fresh administrator verification requires Entra's `auth_time` optional ID-token claim. The setup script configures it. For an application configured manually in the portal, add the `auth_time` optional claim to ID tokens before relying on administrator mutations. + Hub sign-out revokes the opaque Hub session. It does not assume or initiate Entra single sign-out. Provider logout can be added later as a separately configured deployment behavior. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index e63cbbed..27d1cc15 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -28,7 +28,7 @@ Hub sessions use a random opaque cookie and store only its digest. Authenticatio Artifact publishing uses a separate workload identity boundary. Prototype administrators issue prototype-scoped credentials with `publish` and/or `read-status`; Hub returns the raw bearer token once and stores only its digest. Workload credentials never create browser sessions or inherit user, installation, repository, or source-provider access. See [Artifact ingestion](artifact-ingestion.md). -Sign-out revokes the current database session. Provider logout is optional and is not assumed. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh OIDC verification requests `prompt=login` and `max_age=0`, requires a current `auth_time`, and must return the same external identity before administrator mutations. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. +Sign-out revokes the current database session. Provider logout is optional and is not assumed. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh OIDC verification requests `prompt=login` and `max_age=0`, requires an `auth_time` no older than the browser-bound authorization attempt, and must return the same external identity before administrator mutations. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. ## Persisted boundaries diff --git a/hub/script/setup-entra-oidc.ps1 b/hub/script/setup-entra-oidc.ps1 index f9b3810b..ff56c25a 100644 --- a/hub/script/setup-entra-oidc.ps1 +++ b/hub/script/setup-entra-oidc.ps1 @@ -93,6 +93,44 @@ function Get-AppRoles { ) } +function ConvertTo-WritableOptionalClaim { + param($Claim) + + return @{ + Name = $Claim.Name + Source = $Claim.Source + Essential = $Claim.Essential + AdditionalProperties = @($Claim.AdditionalProperties) + } +} + +function Get-ConvergedOptionalClaims { + param($Application) + + $current = if ($Application) { $Application.OptionalClaims } else { $null } + $currentIdToken = if ($current) { @($current.IdToken) } else { @() } + $currentAccessToken = if ($current) { @($current.AccessToken) } else { @() } + $currentSaml2Token = if ($current) { @($current.Saml2Token) } else { @() } + $idToken = $currentIdToken.Where({ + $_.Name -cne "auth_time" + }).ForEach({ ConvertTo-WritableOptionalClaim -Claim $_ }) + $idToken += @{ + Name = "auth_time" + Source = $null + Essential = $true + AdditionalProperties = @() + } + return @{ + IdToken = @($idToken) + AccessToken = $currentAccessToken.ForEach({ + ConvertTo-WritableOptionalClaim -Claim $_ + }) + Saml2Token = $currentSaml2Token.ForEach({ + ConvertTo-WritableOptionalClaim -Claim $_ + }) + } +} + function Get-HubConfiguration { param( [string] $ResolvedTenantId, @@ -236,6 +274,13 @@ function Test-ApplicationDrift { ) if ($Application.SignInAudience -ne "AzureADMyOrg") { return $true } + $authTimeClaims = if ($Application.OptionalClaims) { + @($Application.OptionalClaims.IdToken).Where({ + $_.Name -ceq "auth_time" + }) + } + else { @() } + if ($authTimeClaims.Count -ne 1 -or -not $authTimeClaims[0].Essential) { return $true } $actualRedirectUris = @(@($Application.Web.RedirectUris) | Sort-Object -CaseSensitive) $expectedRedirectUris = @(@($DesiredRedirectUris) | Sort-Object -CaseSensitive) if ($actualRedirectUris.Count -ne $expectedRedirectUris.Count) { return $true } @@ -441,17 +486,20 @@ function Invoke-Apply { $application = New-MgApplication -DisplayName $DisplayName ` -SignInAudience "AzureADMyOrg" ` -Web @{ RedirectUris = @($CallbackUrl) } ` + -OptionalClaims (Get-ConvergedOptionalClaims -Application $null) ` -AppRoles (Get-AppRoles) ` -Tags @($scriptOwnerTag) } else { $roles = Get-ConvergedRoles -Application $application + $optionalClaims = Get-ConvergedOptionalClaims -Application $application $redirectUris = Get-ConvergedRedirectUris -Application $application -CallbackUrl $CallbackUrl if (Test-ApplicationDrift -Application $application ` -DesiredRoles $roles -DesiredRedirectUris $redirectUris) { Update-MgApplication -ApplicationId $application.Id ` -SignInAudience "AzureADMyOrg" ` -Web @{ RedirectUris = $redirectUris } ` + -OptionalClaims $optionalClaims ` -AppRoles $roles | Out-Null $application = Get-ApplicationByAppId -ApplicationId $application.AppId } diff --git a/hub/script/tests/setup-entra-oidc.test.mjs b/hub/script/tests/setup-entra-oidc.test.mjs index 61ac72ed..5d549e20 100644 --- a/hub/script/tests/setup-entra-oidc.test.mjs +++ b/hub/script/tests/setup-entra-oidc.test.mjs @@ -86,6 +86,53 @@ describe("setup-entra-oidc.ps1", () => { assert.doesNotMatch(convergedRoles, /\bOrigin\s*=/) }) + it("sends an essential auth_time ID-token claim when creating or updating the application", () => { + const apply = functionBody("Invoke-Apply") + const optionalClaims = functionBody("Get-ConvergedOptionalClaims") + + assert.match(optionalClaims, /Name = "auth_time"/) + assert.match(optionalClaims, /Essential = \$true/) + assert.match( + apply, + /New-MgApplication[\s\S]*-OptionalClaims \(Get-ConvergedOptionalClaims -Application \$null\)/, + ) + assert.match( + apply, + /\$optionalClaims = Get-ConvergedOptionalClaims -Application \$application[\s\S]*Update-MgApplication[\s\S]*-OptionalClaims \$optionalClaims/, + ) + }) + + it("preserves unrelated ID, access, and SAML optional claims through writable projections", () => { + const projection = functionBody("ConvertTo-WritableOptionalClaim") + const optionalClaims = functionBody("Get-ConvergedOptionalClaims") + + assert.match(projection, /Name = \$Claim\.Name/) + assert.match(projection, /Source = \$Claim\.Source/) + assert.match(projection, /Essential = \$Claim\.Essential/) + assert.match(projection, /AdditionalProperties = @\(\$Claim\.AdditionalProperties\)/) + assert.match( + optionalClaims, + /\$currentIdToken\.Where\(\{[\s\S]*\$_.Name -cne "auth_time"[\s\S]*\}\)\.ForEach\(\{ ConvertTo-WritableOptionalClaim -Claim \$_ \}\)/, + ) + assert.match(optionalClaims, /IdToken = @\(\$idToken\)/) + assert.match( + optionalClaims, + /AccessToken = \$currentAccessToken\.ForEach\(\{[\s\S]*ConvertTo-WritableOptionalClaim -Claim \$_/, + ) + assert.match( + optionalClaims, + /Saml2Token = \$currentSaml2Token\.ForEach\(\{[\s\S]*ConvertTo-WritableOptionalClaim -Claim \$_/, + ) + }) + + it("treats missing, duplicate, or nonessential auth_time claims as application drift", () => { + const drift = functionBody("Test-ApplicationDrift") + + assert.match(drift, /\$authTimeClaims = if \(\$Application\.OptionalClaims\)/) + assert.match(drift, /\$_.Name -ceq "auth_time"/) + assert.match(drift, /\$authTimeClaims\.Count -ne 1 -or -not \$authTimeClaims\[0\]\.Essential/) + }) + it("treats empty and case-changed redirect URI lists as drift", () => { const drift = functionBody("Test-ApplicationDrift") diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts index bd73cc0a..8d4d2d65 100644 --- a/hub/server/domains/authentication/complete-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -52,7 +52,7 @@ export async function completeOidcAuthorization( profile = purpose === "verify" ? await oidc.identify(code, attempt.pkceVerifier, attempt.nonce, { - maximumAuthenticationAgeSeconds: 0, + authenticatedAfter: new Date(attempt.expiresAt.getTime() - 10 * 60_000), }) : await oidc.identify(code, attempt.pkceVerifier, attempt.nonce) } catch { diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index fb94b0f9..6c215446 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -266,6 +266,11 @@ describe("createAuthenticationService() OIDC", () => { }) it("forwards fresh-authentication options only for session verification", async () => { + const verificationAttempt = attempt({ + purpose: "verify", + originatingSessionId: "session", + expiresAt: new Date(now.getTime() + 4 * 60_000), + }) const session = { id: "session", userId: "user", @@ -282,9 +287,7 @@ describe("createAuthenticationService() OIDC", () => { session: vi.fn().mockResolvedValue(session), sessionById: vi.fn().mockResolvedValue(session), membership: vi.fn().mockResolvedValue(activeMembership()), - consumeAuthorizationAttempt: vi - .fn() - .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(verificationAttempt), }) const { authentication, client } = service(identityRecords) @@ -298,7 +301,7 @@ describe("createAuthenticationService() OIDC", () => { { fresh: true }, ) expect(client.identify).toHaveBeenCalledWith("code", "pkce-verifier", "one-use-nonce", { - maximumAuthenticationAgeSeconds: 0, + authenticatedAfter: new Date(verificationAttempt.expiresAt.getTime() - 10 * 60_000), }) }) diff --git a/hub/server/integrations/oidc/create-client/index.test.ts b/hub/server/integrations/oidc/create-client/index.test.ts index 5fc0f08b..250499c9 100644 --- a/hub/server/integrations/oidc/create-client/index.test.ts +++ b/hub/server/integrations/oidc/create-client/index.test.ts @@ -210,12 +210,17 @@ describe("createOidcClient()", () => { expect(JSON.stringify(profile)).not.toContain(token) }) - it("accepts fresh identification at the authentication-time tolerance boundary", async () => { + it.each([ + ["the lower clock-skew boundary", -5], + ["a long provider flow after the authorization attempt started", 30], + ["the future clock-skew boundary", 9 * 60 + 5], + ])("accepts fresh identification at %s", async (_name, secondsAfterAttemptStart) => { vi.useFakeTimers({ toFake: ["Date"] }) vi.setSystemTime(new Date("2026-10-29T10:00:00.000Z")) + const authenticatedAfter = new Date("2026-10-29T09:51:00.000Z") const key = await signingKey() const token = await idToken(key, { - auth_time: Math.floor(Date.now() / 1_000) - 5, + auth_time: Math.floor(authenticatedAfter.getTime() / 1_000) + secondsAfterAttemptStart, }) const client = createOidcClient(configuration, { fetch: providerFetch({ token, keys: [key.jwk] }), @@ -223,27 +228,33 @@ describe("createOidcClient()", () => { await expect( client.identify("code", "verifier", "expected-nonce", { - maximumAuthenticationAgeSeconds: 0, + authenticatedAfter, }), ).resolves.toMatchObject({ subject: "immutable-subject" }) }) it.each([ ["missing", undefined], - ["stale", Math.floor(new Date("2026-10-29T10:00:00.000Z").getTime() / 1_000) - 6], - ["materially future", Math.floor(new Date("2026-10-29T10:00:00.000Z").getTime() / 1_000) + 6], + ["before the authorization attempt", -6], + ["materially future", 9 * 60 + 6], ])("rejects fresh identification with a %s authentication time", async (_name, authTime) => { vi.useFakeTimers({ toFake: ["Date"] }) vi.setSystemTime(new Date("2026-10-29T10:00:00.000Z")) + const authenticatedAfter = new Date("2026-10-29T09:51:00.000Z") const key = await signingKey() - const token = await idToken(key, { auth_time: authTime }) + const token = await idToken(key, { + auth_time: + authTime === undefined + ? undefined + : Math.floor(authenticatedAfter.getTime() / 1_000) + authTime, + }) const client = createOidcClient(configuration, { fetch: providerFetch({ token, keys: [key.jwk] }), }) await expect( client.identify("code", "verifier", "expected-nonce", { - maximumAuthenticationAgeSeconds: 0, + authenticatedAfter, }), ).rejects.toThrow("OIDC authentication is not fresh.") }) diff --git a/hub/server/integrations/oidc/create-client/index.ts b/hub/server/integrations/oidc/create-client/index.ts index 0ff56a4d..62273111 100644 --- a/hub/server/integrations/oidc/create-client/index.ts +++ b/hub/server/integrations/oidc/create-client/index.ts @@ -187,15 +187,13 @@ export function createOidcClient( ) { throw new Error("OIDC token authorized party is invalid.") } - if (options?.maximumAuthenticationAgeSeconds !== undefined) { - const authenticationAge = - typeof claims.auth_time === "number" - ? Math.floor(Date.now() / 1_000) - claims.auth_time - : null + if (options?.authenticatedAfter) { + const authenticatedAt = + typeof claims.auth_time === "number" ? claims.auth_time * 1_000 : null if ( - authenticationAge === null || - authenticationAge < -5 || - authenticationAge > options.maximumAuthenticationAgeSeconds + 5 + authenticatedAt === null || + authenticatedAt < options.authenticatedAfter.getTime() - 5_000 || + authenticatedAt > Date.now() + 5_000 ) { throw new Error("OIDC authentication is not fresh.") } diff --git a/hub/server/integrations/oidc/create-client/types.ts b/hub/server/integrations/oidc/create-client/types.ts index eb5401f2..4676fa4d 100644 --- a/hub/server/integrations/oidc/create-client/types.ts +++ b/hub/server/integrations/oidc/create-client/types.ts @@ -33,7 +33,7 @@ export interface OidcClient { code: string, pkceVerifier: string, nonce: string, - options?: { maximumAuthenticationAgeSeconds?: number }, + options?: { authenticatedAfter?: Date }, ): Promise } From bdc01d6a5fff90b8450474ec74065e30c2c9200d Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 07:22:28 -0700 Subject: [PATCH 54/56] fix(hub): require OIDC verification freshness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 2 +- .../complete-oidc-authorization/index.ts | 2 +- .../create-service/index.db.test.ts | 62 +++++++++++++++++++ .../create-service/oidc.test.ts | 4 +- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63da68f2..260bce38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,6 @@ cd starter npm ci npm run typecheck npm test -npm run test:entra-setup npm run build ``` @@ -104,6 +103,7 @@ npm run lint npm run db:validate npm run typecheck npm test +npm run test:entra-setup npm run build npm run storybook:build ``` diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts index 8d4d2d65..50bf8178 100644 --- a/hub/server/domains/authentication/complete-oidc-authorization/index.ts +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -377,7 +377,7 @@ export async function completeOidcAuthorization( authenticationProviderRevisionId: oidcProviderRevisionId, tokenDigest: digestSecret(sessionToken), signedInAt: now(), - freshAuthorizedAt: now(), + freshAuthorizedAt: null, }) await records.audit({ event: "session.sign_in", diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 1dc5f4c7..33ee1531 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" +import { createAdministrationService } from "#server/domains/administration/create-service" import { createAuthenticationService } from "#server/domains/authentication/create-service" import { createIdentityStore } from "#server/domains/identity/create-store" import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" @@ -514,6 +515,67 @@ describe("createAuthenticationService() database behavior", () => { ]) }) + it("keeps a new OIDC administrator session non-fresh until verification permits a mutation", async () => { + const { prisma, store } = await database() + const graph = await createOidcMember(store, "administrator") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("sign-in-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "sign-in-nonce", + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const authentication = oidcService(store, graph.provider.id, "administrator") + const signedIn = await authentication.complete( + "provider-code", + "sign-in-state", + "browser-token", + ) + const session = await prisma.session.findUniqueOrThrow({ + where: { tokenDigest: digestSecret(signedIn.sessionToken!) }, + }) + const administration = createAdministrationService({ store }) + const invitation = { + provider: "oidc", + issuer: oidcIssuer, + subject: "b1e2d3c4-a5b6-4789-abcd-ef0123456789", + role: "member" as const, + } + + expect(session.freshAuthorizedAt).toBeNull() + await expect(administration.invite(signedIn.sessionToken!, invitation)).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: session.id, + stateDigest: digestSecret("verify-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "verify-nonce", + purpose: "verify", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + await expect( + authentication.complete("provider-code", "verify-state", "browser-token"), + ).resolves.toEqual({ purpose: "verify" }) + + const verifiedSession = await prisma.session.findUniqueOrThrow({ where: { id: session.id } }) + expect(verifiedSession.freshAuthorizedAt).toBeInstanceOf(Date) + await expect(administration.invite(signedIn.sessionToken!, invitation)).resolves.toMatchObject( + invitation, + ) + }) + it("migrates a changed provider subject without duplicating the existing identity graph", async () => { const { prisma, store } = await database() const graph = await createOidcMember(store, "member") diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts index 6c215446..87f94743 100644 --- a/hub/server/domains/authentication/create-service/oidc.test.ts +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -82,7 +82,7 @@ function records(overrides: Partial = {}) { authenticationProviderRevisionId: providerRevision.id, tokenDigest: "stored-digest", signedInAt: now, - freshAuthorizedAt: now, + freshAuthorizedAt: null, }), verifySession: vi.fn(), deleteSession: vi.fn(), @@ -1034,6 +1034,8 @@ describe("createAuthenticationService() OIDC", () => { expect.objectContaining({ tokenDigest: digestSecret(result.sessionToken!), authenticationProviderRevisionId: providerRevision.id, + signedInAt: now, + freshAuthorizedAt: null, }), ) const persistence = JSON.stringify([ From 21675932cb00539f4d53b83caf0c1efa68254076 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 08:00:18 -0700 Subject: [PATCH 55/56] fix(hub): start OIDC admin verification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../application/authentication/index.test.tsx | 130 ++++++++++++++++++ .../application/authentication/index.tsx | 20 ++- 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 hub/app/javascript/application/authentication/index.test.tsx diff --git a/hub/app/javascript/application/authentication/index.test.tsx b/hub/app/javascript/application/authentication/index.test.tsx new file mode 100644 index 00000000..275b8116 --- /dev/null +++ b/hub/app/javascript/application/authentication/index.test.tsx @@ -0,0 +1,130 @@ +import { ThemeProvider } from "@primer/react-brand" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { AuthenticationApplication } from "." + +describe("AuthenticationApplication()", () => { + it("starts fresh verification instead of presenting a failed administration mutation as success", async () => { + const authorizationUrl = "#provider-authorization" + const session = { + authenticated: true, + configured: true, + provider: "github", + providerLabel: "GitHub", + csrfToken: "session-csrf-token", + user: { id: "administrator", displayName: "Hub administrator" }, + membership: { id: "administrator-membership", role: "administrator", status: "active" }, + } + const administration = { + memberships: [ + { + id: "member-membership", + status: "active", + role: "member", + user: { + id: "member", + displayName: "Hub member", + externalIdentities: [], + }, + }, + ], + invitations: [], + } + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (input === "/api/session" && init?.method === undefined) { + return Promise.resolve(Response.json(session)) + } + if (input === "/api/administration" && init?.method === undefined) { + return Promise.resolve(Response.json(administration)) + } + if (input === "/api/administration/memberships/member-membership") { + return Promise.resolve( + Response.json( + { error: "fresh_authorization_required", message: "Verify before continuing." }, + { status: 403 }, + ), + ) + } + if (input === "/api/session/verify") { + return Promise.resolve(Response.json({ authorizationUrl })) + } + return Promise.reject(new Error("Unexpected request")) + }) + vi.stubGlobal("fetch", fetch) + render( + + + , + ) + const member = (await screen.findByText("Hub member")).closest("li")! + + fireEvent.click(screen.getByRole("button", { name: "Suspend" })) + + await waitFor(() => expect(window.location.hash).toBe(authorizationUrl)) + expect(fetch).toHaveBeenCalledWith("/api/session/verify", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": "session-csrf-token" }, + }) + expect(member).toHaveTextContent("member ยท active") + expect(fetch.mock.calls.filter(([input]) => input === "/api/session")).toHaveLength(1) + }) + + it("shows a useful error when fresh verification cannot start", async () => { + const session = { + authenticated: true, + configured: true, + provider: "github", + providerLabel: "GitHub", + csrfToken: "session-csrf-token", + user: { id: "administrator", displayName: "Hub administrator" }, + membership: { id: "administrator-membership", role: "administrator", status: "active" }, + } + const administration = { + memberships: [ + { + id: "member-membership", + status: "active", + role: "member", + user: { + id: "member", + displayName: "Hub member", + externalIdentities: [], + }, + }, + ], + invitations: [], + } + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (input === "/api/session" && init?.method === undefined) { + return Promise.resolve(Response.json(session)) + } + if (input === "/api/administration" && init?.method === undefined) { + return Promise.resolve(Response.json(administration)) + } + if (input === "/api/administration/memberships/member-membership") { + return Promise.resolve( + Response.json({ error: "fresh_authorization_required" }, { status: 403 }), + ) + } + if (input === "/api/session/verify") { + return Promise.resolve(Response.json({ error: "provider_unavailable" }, { status: 503 })) + } + return Promise.reject(new Error("Unexpected request")) + }) + vi.stubGlobal("fetch", fetch) + render( + + + , + ) + await screen.findByText("Hub member") + const currentUrl = window.location.href + + fireEvent.click(screen.getByRole("button", { name: "Suspend" })) + + expect(await screen.findByRole("alert")).toHaveTextContent("Hub could not start verification.") + expect(window.location.href).toBe(currentUrl) + }) +}) diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx index b9745cbf..7b242358 100644 --- a/hub/app/javascript/application/authentication/index.tsx +++ b/hub/app/javascript/application/authentication/index.tsx @@ -116,6 +116,18 @@ export function AuthenticationApplication() { } } + async function startVerification() { + if (!session) throw new Error("Session is unavailable.") + const response = await fetch("/api/session/verify", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!response.ok) throw new Error("Hub could not start verification.") + const data = (await response.json()) as { authorizationUrl: string } + window.location.assign(data.authorizationUrl) + } + async function updateAdministration(path: string, method: string, body?: unknown) { if (!session) return setLoading(true) @@ -131,8 +143,12 @@ export function AuthenticationApplication() { body: body === undefined ? undefined : JSON.stringify(body), }) if (!response.ok) { - const data = (await response.json()) as { error?: string } - throw new Error(data.error ?? "Administration request failed") + const data = (await response.json()) as { error?: string; message?: string } + if (data.error === "fresh_authorization_required") { + await startVerification() + return + } + throw new Error(data.message ?? data.error ?? "Administration request failed") } const current = await fetchSession() setSession(current) From 12b0b5e0b5003c4f2d0d34e864d392ba64173b7e Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 08:28:51 -0700 Subject: [PATCH 56/56] fix(hub): recover from OIDC role changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../application/authentication/index.test.tsx | 45 +++++++++++++++++ .../application/authentication/index.tsx | 7 ++- .../index.test.tsx | 24 ++++++++++ .../membership-administration-page/index.tsx | 12 +++-- .../stories.tsx | 1 + .../membership-administration-page/types.ts | 1 + .../routes/api/auth/callback/get/index.ts | 12 ++++- hub/server/routes/authentication.test.ts | 48 +++++++++++++++++++ 8 files changed, 144 insertions(+), 6 deletions(-) diff --git a/hub/app/javascript/application/authentication/index.test.tsx b/hub/app/javascript/application/authentication/index.test.tsx index 275b8116..6e49a8a3 100644 --- a/hub/app/javascript/application/authentication/index.test.tsx +++ b/hub/app/javascript/application/authentication/index.test.tsx @@ -5,6 +5,51 @@ import { describe, expect, it, vi } from "vitest" import { AuthenticationApplication } from "." describe("AuthenticationApplication()", () => { + it("offers provider-role recovery through the configured sign-in flow", async () => { + window.history.replaceState({}, "", "/?authentication=failed&reason=provider_role_changed") + const authorizationUrl = "#provider-role-recovery" + const session = { + authenticated: true, + configured: true, + provider: "oidc", + providerLabel: "Company SSO", + csrfToken: "session-csrf-token", + user: { id: "administrator", displayName: "Hub administrator" }, + membership: { id: "administrator-membership", role: "administrator", status: "active" }, + } + const administration = { memberships: [], invitations: [] } + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (input === "/api/session" && init?.method === undefined) { + return Promise.resolve(Response.json(session)) + } + if (input === "/api/administration" && init?.method === undefined) { + return Promise.resolve(Response.json(administration)) + } + if (input === "/api/auth/oidc") { + return Promise.resolve(Response.json({ authorizationUrl })) + } + return Promise.reject(new Error("Unexpected request")) + }) + vi.stubGlobal("fetch", fetch) + render( + + + , + ) + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your provider role changed. Sign in again to update your Hub access.", + ) + fireEvent.click(screen.getByRole("button", { name: "Sign in again" })) + + await waitFor(() => expect(window.location.hash).toBe(authorizationUrl)) + expect(fetch).toHaveBeenCalledWith("/api/auth/oidc", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": "session-csrf-token" }, + }) + }) + it("starts fresh verification instead of presenting a failed administration mutation as success", async () => { const authorizationUrl = "#provider-authorization" const session = { diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx index 7b242358..2e0120ce 100644 --- a/hub/app/javascript/application/authentication/index.tsx +++ b/hub/app/javascript/application/authentication/index.tsx @@ -35,7 +35,11 @@ async function fetchSession(): Promise { export function AuthenticationApplication() { const [session, setSession] = useState(null) - const [error, setError] = useState(null) + const [error, setError] = useState(() => + new URLSearchParams(window.location.search).get("reason") === "provider_role_changed" + ? "Your provider role changed. Sign in again to update your Hub access." + : null, + ) const [loading, setLoading] = useState(true) const [administration, setAdministration] = useState(null) @@ -188,6 +192,7 @@ export function AuthenticationApplication() { onRevokeInvitation={(invitationId) => void updateAdministration(`/api/administration/invitations/${invitationId}`, "DELETE") } + onSignIn={() => void signIn()} onSignOut={() => void signOut()} /> ) diff --git a/hub/app/javascript/pages/membership-administration-page/index.test.tsx b/hub/app/javascript/pages/membership-administration-page/index.test.tsx index ab291bd7..1d0d2373 100644 --- a/hub/app/javascript/pages/membership-administration-page/index.test.tsx +++ b/hub/app/javascript/pages/membership-administration-page/index.test.tsx @@ -5,6 +5,29 @@ import { describe, expect, it, vi } from "vitest" import { MembershipAdministrationPage } from "." describe("MembershipAdministrationPage()", () => { + it("lets an administrator sign in again", () => { + const onSignIn = vi.fn() + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "Sign in again" })) + + expect(onSignIn).toHaveBeenCalledOnce() + }) + it("offers restoration for suspension but no lifecycle actions after removal", () => { const onChange = vi.fn() render( @@ -38,6 +61,7 @@ describe("MembershipAdministrationPage()", () => { onInvite={vi.fn()} onChange={onChange} onRevokeInvitation={vi.fn()} + onSignIn={vi.fn()} onSignOut={vi.fn()} /> , diff --git a/hub/app/javascript/pages/membership-administration-page/index.tsx b/hub/app/javascript/pages/membership-administration-page/index.tsx index 478fc56e..7c89279e 100644 --- a/hub/app/javascript/pages/membership-administration-page/index.tsx +++ b/hub/app/javascript/pages/membership-administration-page/index.tsx @@ -13,6 +13,7 @@ export function MembershipAdministrationPage({ onInvite, onChange, onRevokeInvitation, + onSignIn, onSignOut, }: MembershipAdministrationPageProps) { const [provider, setProvider] = useState("github") @@ -31,9 +32,14 @@ export function MembershipAdministrationPage({ Hub admission and installation roles are independent of repository access.
- +
+ + +
{error ? ( diff --git a/hub/app/javascript/pages/membership-administration-page/stories.tsx b/hub/app/javascript/pages/membership-administration-page/stories.tsx index 1ab480c5..79576207 100644 --- a/hub/app/javascript/pages/membership-administration-page/stories.tsx +++ b/hub/app/javascript/pages/membership-administration-page/stories.tsx @@ -62,6 +62,7 @@ const meta = { onInvite: () => undefined, onChange: () => undefined, onRevokeInvitation: () => undefined, + onSignIn: () => undefined, onSignOut: () => undefined, }, } satisfies Meta diff --git a/hub/app/javascript/pages/membership-administration-page/types.ts b/hub/app/javascript/pages/membership-administration-page/types.ts index 9498ffa5..052a0139 100644 --- a/hub/app/javascript/pages/membership-administration-page/types.ts +++ b/hub/app/javascript/pages/membership-administration-page/types.ts @@ -44,5 +44,6 @@ export interface MembershipAdministrationPageProps { input: { role?: "member" | "administrator"; status?: "active" | "suspended" | "removed" }, ) => void onRevokeInvitation: (invitationId: string) => void + onSignIn: () => void onSignOut: () => void } diff --git a/hub/server/routes/api/auth/callback/get/index.ts b/hub/server/routes/api/auth/callback/get/index.ts index c296b6c1..23cbc8cb 100644 --- a/hub/server/routes/api/auth/callback/get/index.ts +++ b/hub/server/routes/api/auth/callback/get/index.ts @@ -14,7 +14,10 @@ function isSingleString(value: unknown): value is string { return typeof value === "string" } -function failureUrl(origin: string, reason: "invalid_callback" | "provider_error"): string { +function failureUrl( + origin: string, + reason: "invalid_callback" | "provider_error" | "provider_role_changed", +): string { const url = new URL(origin) url.searchParams.set("authentication", "failed") url.searchParams.set("reason", reason) @@ -57,7 +60,12 @@ export async function get( response.redirect(303, `${authentication.settings.origin}/?authentication=complete`) } catch (error) { if (error instanceof AuthenticationError) { - response.redirect(303, `${authentication.settings.origin}/?authentication=failed`) + response.redirect( + 303, + error.code === "provider_role_changed" + ? failureUrl(authentication.settings.origin, "provider_role_changed") + : `${authentication.settings.origin}/?authentication=failed`, + ) return } throw error diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index dfddf597..c14c3fab 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -570,6 +570,54 @@ describe("authentication routes", () => { ) }) + it("preserves the provider role recovery reason without exposing callback secrets", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication( + { + complete: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 403, + "provider_role_changed", + "private provider role reconciliation failure", + ), + ), + }, + true, + 0, + "oidc", + ) + const baseUrl = await serve(runtime, logger) + const code = "private-provider-code" + const state = "s".repeat(43) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ code, state })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.status).toBe(303) + expect(response.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=provider_role_changed", + ) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify([...response.headers])).not.toMatch( + /private-provider-code|sssssssssssssssssssssssssssssssssssssssssss/, + ) + expect(JSON.stringify(records)).not.toMatch( + /private-provider-code|sssssssssssssssssssssssssssssssssssssssssss|role reconciliation/, + ) + }) + it("allows the initiation limit and rejects the next request without calling the service", async () => { const runtime = authentication() const baseUrl = await serve(runtime)