diff --git a/hub/.env.example b/hub/.env.example index ed2821cd..a7381621 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -13,3 +13,16 @@ DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica # Only database-enabled tests require this URL and CREATEDB permission. TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres + +# Select the GitHub source 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. +# GITHUB_APP_ID= +# GITHUB_INSTALLATION_ID= +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= +# GITHUB_PRIVATE_KEY= diff --git a/hub/docs/README.md b/hub/docs/README.md index 5d471ed3..66176753 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -9,6 +9,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Database](database.md): Prisma schema conventions, migrations, and the shared client. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Background jobs](background-jobs.md): PostgreSQL queues, workers, schedules, setup, and test isolation. +- [Identity](identity.md): credential storage and providers. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. - [Prototype canvas](prototype-canvas.md): the separately retained extension. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index bf72e320..81ffb9ce 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -8,7 +8,7 @@ The team has chosen TypeScript, Express for the API, React and Vite for the fron The API responds to a health check, and the frontend displays the logo with light and dark themes. Storybook keeps the existing components and pages available for reuse. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. Sign-in, deployment, and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity foundation](identity.md) separates Hub users, external identities, installation membership, authentication provider configuration, sessions, authorization attempts, security records, and GitHub source credentials. Sign-in routes, deployment, and product APIs have not been built. This app cannot yet authenticate users, evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. ## Backend diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 60976740..bd9fcb49 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -37,6 +37,16 @@ Export route handlers by HTTP method, such as `get()`, and alias them to descrip Add directories when their functions are needed. Do not add empty implementations just to fill out the structure. +Keep function implementations flat. A composition factory may bind imported functions to dependencies, but must not contain their implementations. Put schemas and constants in a colocated `constants.ts`, and types in `types.ts`. + +Keep domain contracts with their domains, GitHub types and errors with that integration, and database inputs with their repositories. Keep only general helpers in utilities. + ## Database access The [shared Prisma client](database.md#shared-client) lives in `server/integrations/prisma/index.ts`. Domains may pass it to repositories for database queries without letting repositories import integrations. Keep model definitions, naming, and migrations consistent with the [database conventions](database.md#schema-conventions). + +Compose repository functions in domains. The identity store binds standalone queries to a database client or transaction there; a repository must not import its neighboring queries to build that store. + +`staticIdentityStoreRecordId` identifies the row that serializes identity store transactions. It is not a user, provider configuration, or GitHub installation. Keep it with the identity store state repository. + +GitHub source credentials use a separate fixed credentials row. Authentication provider configuration must not read or write those credentials. diff --git a/hub/docs/coding-conventions.md b/hub/docs/coding-conventions.md index 31360d5d..cd990769 100644 --- a/hub/docs/coding-conventions.md +++ b/hub/docs/coding-conventions.md @@ -9,8 +9,12 @@ Follow the [database conventions](database.md#schema-conventions) for Prisma mod - Use TypeScript for new application code and tests. - Put each standalone function or component in a kebab-case directory with `index.ts` or `index.tsx`, colocated tests, types, and styles. - Export at most one function per file and import exact directories rather than sibling-export barrels. +- Keep helpers in their own function directories. Put schemas and constants in `constants.ts` and types in `types.ts`. - Group package, alias, and relative imports separately. -- Omit `/index.ts` and `/index.tsx` from imports in frontend code and Vitest tests. Keep explicit `.ts` file paths in server runtime and Node-loaded tooling, including Storybook configuration. Native Node does not resolve directory imports; the server compiler rewrites its paths for production. +- Use `@/` for browser code and `#server/` for API code. Import function directories without `/index.ts`, such as `#server/utils/digest-secret`. Import colocated constants and types from their named files. +- [Package imports](../package.json) resolve `#server/` to source with Node's `development` condition and to compiled JavaScript otherwise. `npm run dev:api` selects that condition; `npm start` uses compiled files. TypeScript and Vitest resolve source through the same mappings. Native Node package aliases must start with `#`; `@/` remains browser-only. +- Keep explicit `.ts` paths for relative imports in native Node modules and tooling, including Storybook configuration. The server compiler rewrites relative extensions for the build. +- Prefer named alias imports such as `#server/integrations/github/identify-user/constants` over relative paths when a mapping exists. Vite resolves extensionless relative paths, but native Node does not. TypeScript `paths` alone cannot make `@/server/...` work in Node or rewrite it in emitted JavaScript. - Keep HTTP parsing and responses outside domains. Validate input and return explicit errors when introducing real API behavior. ## Frontend layers diff --git a/hub/docs/database.md b/hub/docs/database.md index 1663e7f1..386e4a56 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 provider-neutral [identity foundation](identity.md); product models have not been added. [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. diff --git a/hub/docs/identity.md b/hub/docs/identity.md new file mode 100644 index 00000000..8dbd6f54 --- /dev/null +++ b/hub/docs/identity.md @@ -0,0 +1,36 @@ +# Hub identity + +Hub persists a provider-neutral identity foundation. User authentication proves an external identity. Hub installation membership records whether that user belongs to this Hub and whether they are an administrator. This persistence layer does not define prototype access rules. + +## Persisted boundaries + +- `User` is the Hub-owned account. It does not contain a provider identifier or administrator flag. +- `ExternalIdentity` links a provider, issuer, and subject to one Hub user. One user may link several identities. The issuer is part of uniqueness so separate OIDC tenants may reuse subjects safely. +- `InstallationMembership` stores a user's status, role, and source for this Hub installation. +- `AuthenticationProvider` and its versioned revisions store authentication configuration without tying it to one provider. An OIDC configuration does not require GitHub source credentials. +- `Session` records the Hub user, external identity, and authentication provider revision that established it. Rotating GitHub source credentials does not change session identity context. +- `AuthorizationAttempt` stores digested state and browser bindings, a PKCE verifier, an optional OIDC nonce, provider revision, purpose, optional originating session, and expiry. Consumption deletes the record in the same transaction so concurrent callbacks cannot reuse it. +- `SecurityAudit` stores the event and minimal optional identity context. + +Queries are available without a lock. Mutations run through one database transaction that locks a fixed store row. Related records and audit events therefore commit or roll back together, and overlapping mutations observe committed state. + +Security audit queries return at most 100 records from the previous 90 days. Each new audit write deletes records older than 90 days. Operators who require a longer security history must export records before that limit. + +## GitHub source credentials + +GitHub source access is an optional integration boundary, not the global identity boundary. Set `HUB_GITHUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): + +| 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. | + +Both providers currently validate an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. + +Database credentials use AES-256-GCM with contextual additional authenticated data. Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors. Hub never falls back to another provider. + +## Foundation scope + +This layer provides persistence only. It does not add HTTP sign-in routes, production OIDC or SCIM flows, prototype or product models, an authorization evaluator, GitHub permission synchronization, artifact ingestion, or UI. GitHub authentication configuration and GitHub source credentials remain separate so either capability can change without redefining Hub identity. + +Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/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/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 6d23554f..b08d69cb 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 project-wide test helpers in the appropriate `test/` directory and function-specific helpers beside their owning function. TypeScript checks both, but the API build excludes them. + ## Parallel database tests Each test run uses separate databases so parallel tests cannot change another worker's records or clear the development database. Vitest runs files in parallel in [forked worker processes](https://vitest.dev/config/pool). Cases within a worker run sequentially, including cases marked concurrent, so database resets cannot erase another case's records. Use `--maxWorkers` to set file-level parallelism. @@ -51,7 +53,7 @@ Use `.test.ts` or `.test.tsx` for ordinary cases. Use `.db.test.ts` or `.db.test Start the PostgreSQL service as described in [Development](development.md) and set `TEST_DATABASE_URL` using `.env.example`. The devcontainer supplies this variable. Use a local database user with permission to create and drop databases, never a production connection. -The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. The job integration cases exercise real PostgreSQL queues, schedules, and database resets. +The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise external identity linking and uniqueness, installation membership, provider-specific authorization attempts, session context, independent GitHub credentials, transaction rollback, and concurrent store locks. Job integration cases exercise real PostgreSQL queues, schedules, and database resets. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. @@ -59,7 +61,7 @@ The database setup follows this lifecycle: 1. Before workers start, setup creates a template database with a unique name for each project and run. It applies the current Prisma schema with `prisma db push` and BullMQ's migrations, closes those setup connections, then copies the template for every worker slot. 2. Each worker receives its own `DATABASE_URL` before application modules load. Setup uses Vitest's [`VITEST_POOL_ID`](https://vitest.dev/guide/migration/jest#envs), which stays within the configured worker limit. -3. Before every case, setup empties application and job tables across user schemas and resets generated identity counters. It preserves `public._prisma_migrations` and `bullmq.migration`. Each case creates its own records; there is no shared seed data. +3. Before every case, setup empties application and job tables across user schemas. It preserves `public._prisma_migrations` and `bullmq.migration`. Each case creates its own records; there is no shared seed data. 4. After the run, teardown drops only the databases it created, even if tests left connections open. Unique database names also isolate simultaneous runs. Before clearing tables, cleanup checks that the database name belongs to the test namespace and matches the connected database. diff --git a/hub/package.json b/hub/package.json index 259f8e00..19a98b76 100644 --- a/hub/package.json +++ b/hub/package.json @@ -3,6 +3,20 @@ "version": "0.1.0", "private": true, "type": "module", + "imports": { + "#server/*/constants": { + "development": "./server/*/constants.ts", + "default": "./dist/server/*/constants.js" + }, + "#server/*/types": { + "development": "./server/*/types.ts", + "default": "./dist/server/*/types.js" + }, + "#server/*": { + "development": "./server/*/index.ts", + "default": "./dist/server/*/index.js" + } + }, "engines": { "node": "^24.15.0 || ^26.0.0" }, @@ -30,7 +44,7 @@ "build": "npm run typecheck && vite build && tsc --project tsconfig.server.json", "dev": "npm run dev:client", "dev:client": "vite --host 127.0.0.1", - "dev:api": "node --watch server/index.ts", + "dev:api": "node --conditions=development --watch server/index.ts", "start": "node dist/server/index.js", "preview": "vite preview --host 127.0.0.1", "extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.mjs", diff --git a/hub/schema/authentication-provider-revision.prisma b/hub/schema/authentication-provider-revision.prisma new file mode 100644 index 00000000..1d44e01f --- /dev/null +++ b/hub/schema/authentication-provider-revision.prisma @@ -0,0 +1,23 @@ +model AuthenticationProviderRevision { + // ID columns + id String @id @default(uuid()) @db.Uuid + authenticationProviderId String @map("authentication_provider_id") @db.Uuid + + // Relations + authenticationProvider AuthenticationProvider @relation(fields: [authenticationProviderId], references: [id], onDelete: Cascade) + authorizationAttempts AuthorizationAttempt[] + sessions Session[] + + // Attribute columns + revision Int + issuer String + configuration Json + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([authenticationProviderId, revision]) + @@index([issuer]) + @@map("authentication_provider_revisions") +} diff --git a/hub/schema/authentication-provider.prisma b/hub/schema/authentication-provider.prisma new file mode 100644 index 00000000..9bd0d29c --- /dev/null +++ b/hub/schema/authentication-provider.prisma @@ -0,0 +1,18 @@ +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") +} 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/github-integration-credentials.prisma b/hub/schema/github-integration-credentials.prisma new file mode 100644 index 00000000..81b0ffe2 --- /dev/null +++ b/hub/schema/github-integration-credentials.prisma @@ -0,0 +1,13 @@ +model GitHubIntegrationCredentials { + // ID columns + id String @id @db.Uuid + + // Attribute columns + encryptedCredentials String? @map("encrypted_credentials") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("github_integration_credentials") +} 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 new file mode 100644 index 00000000..f0ba238d --- /dev/null +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -0,0 +1,200 @@ +-- CreateTable +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_credentials" ( + "id" UUID NOT NULL, + "encrypted_credentials" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_integration_credentials_pkey" PRIMARY KEY ("id") +); + +-- SeedData +INSERT INTO "github_integration_credentials" ("id") VALUES ('00000000-0000-4000-8000-000000000001'); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID 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 "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "external_identities" ( + "id" UUID 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 "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), + "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 "authorization_attempts" ( + "id" UUID NOT NULL, + "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 "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 "authentication_providers_key_key" ON "authentication_providers"("key"); + +-- CreateIndex +CREATE INDEX "authentication_provider_revisions_issuer_idx" ON "authentication_provider_revisions"("issuer"); + +-- CreateIndex +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"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +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/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/security-audit.prisma b/hub/schema/security-audit.prisma new file mode 100644 index 00000000..8d46e5e8 --- /dev/null +++ b/hub/schema/security-audit.prisma @@ -0,0 +1,17 @@ +model SecurityAudit { + // ID columns + 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 + + // 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..bd8a5d29 --- /dev/null +++ b/hub/schema/session.prisma @@ -0,0 +1,27 @@ +model Session { + // ID columns + 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) + 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) + + // 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 new file mode 100644 index 00000000..73ed59e8 --- /dev/null +++ b/hub/schema/user.prisma @@ -0,0 +1,19 @@ +model User { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + externalIdentities ExternalIdentity[] + memberships InstallationMembership[] + sessions Session[] + + // Attribute columns + 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) + + @@map("users") +} diff --git a/hub/server/domains/github-integration/create-credential-provider/index.test.ts b/hub/server/domains/github-integration/create-credential-provider/index.test.ts new file mode 100644 index 00000000..60b662bf --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/index.test.ts @@ -0,0 +1,103 @@ +import { generateKeyPairSync } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/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("createGitHubIntegrationCredentialProvider()", () => { + it("starts unconfigured and encrypts database credentials with fresh authenticated ciphertext", () => { + const provider = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const input = credentials() + + expect(provider.name).toBe("database") + expect(provider.read({ encryptedCredentials: null })).toBeNull() + const encrypted = provider.encrypt!(input) + expect(provider.encrypt!(input)).not.toBe(encrypted) + expect(provider.read({ encryptedCredentials: encrypted })).toEqual(input) + expect(encrypted).not.toContain(input.clientSecret) + expect(Buffer.from(encrypted, "base64").toString()).not.toContain(input.privateKey) + + const tampered = Buffer.from(encrypted, "base64") + tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 1 + expect(() => provider.read({ encryptedCredentials: tampered.toString("base64") })).toThrow() + const otherKey = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_KEY: "cd".repeat(32), + }) + expect(() => otherKey.read({ encryptedCredentials: encrypted })).toThrow() + }) + + it("requires a valid encryption key outside the database", () => { + for (const key of [undefined, "ab".repeat(31), "zz".repeat(32)]) { + expect(() => + createGitHubIntegrationCredentialProvider({ HUB_GITHUB_CREDENTIAL_KEY: key }), + ).toThrow() + } + }) + + it("uses complete environment credentials read-only without consulting database ciphertext", () => { + const input = credentials() + const provider = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_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({ encryptedCredentials: "not-database-credentials" })).toEqual(input) + }) + + it("rejects incomplete environment configuration and unknown providers instead of falling back", () => { + const input = credentials() + const environment = { + 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, + 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(() => + createGitHubIntegrationCredentialProvider({ ...environment, [field]: undefined }), + ).toThrow() + } + expect(() => + createGitHubIntegrationCredentialProvider({ + ...environment, + GITHUB_PRIVATE_KEY: "invalid", + }), + ).toThrow() + expect(() => + createGitHubIntegrationCredentialProvider({ + ...environment, + HUB_GITHUB_CREDENTIAL_PROVIDER: "other", + }), + ).toThrow() + }) +}) diff --git a/hub/server/domains/github-integration/create-credential-provider/index.ts b/hub/server/domains/github-integration/create-credential-provider/index.ts new file mode 100644 index 00000000..c63c01dd --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/index.ts @@ -0,0 +1,36 @@ +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 { GitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider/types" + +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, + 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_GITHUB_CREDENTIAL_PROVIDER must be database or environment.") + } + + const encodedKey = environment.HUB_GITHUB_CREDENTIAL_KEY + if (!encodedKey || !/^[a-fA-F0-9]{64}$/.test(encodedKey)) { + throw new Error( + "Set HUB_GITHUB_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/github-integration/create-credential-provider/types.ts b/hub/server/domains/github-integration/create-credential-provider/types.ts new file mode 100644 index 00000000..df7e7068 --- /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 { GitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/types" + +export interface GitHubIntegrationCredentialProvider { + name: "database" | "environment" + read(state: GitHubIntegrationCredentials): GitHubCredentials | null + encrypt?: (credentials: GitHubCredentials) => string +} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts new file mode 100644 index 00000000..4b076a1f --- /dev/null +++ b/hub/server/domains/identity/create-records/index.ts @@ -0,0 +1,48 @@ +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 { getGitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/get-credentials" +import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/save-credentials" +import { getInstallationMembership } from "#server/repositories/installation-memberships/get-membership" +import { saveInstallationMembership } from "#server/repositories/installation-memberships/save-membership" +import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" +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 { createUser } from "#server/repositories/users/create-user" +import { getUser } from "#server/repositories/users/get-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 { + githubIntegrationCredentials: getGitHubIntegrationCredentials.bind(null, database), + saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), + user: getUser.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), + verifySession: verifySession.bind(null, database), + invalidateFreshness: invalidateFreshness.bind(null, database), + deleteSession: deleteSession.bind(null, database), + deleteSessions: deleteSessions.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 new file mode 100644 index 00000000..4ab2035c --- /dev/null +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -0,0 +1,675 @@ +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 + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + 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("exposes queries without exposing mutations outside the store lock", async () => { + const { store } = await database() + + expect(Object.keys(store.read).sort()).toEqual([ + "audits", + "externalIdentity", + "githubIntegrationCredentials", + "membership", + "session", + "sessionById", + "user", + ]) + }) + + it("rolls back GitHub integration, session, and audit changes together", async () => { + const { store } = await database() + const { session } = await createSession(store, "rollback") + await store.lock((records) => + records.saveGitHubIntegrationCredentials("initial-encrypted-fixture"), + ) + + await expect( + store.lock(async (records) => { + await records.saveGitHubIntegrationCredentials("replacement-encrypted-fixture") + await records.deleteSessions() + await expect(records.sessionById(session.id)).resolves.toBeNull() + 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.githubIntegrationCredentials()).toMatchObject({ + encryptedCredentials: "initial-encrypted-fixture", + }) + expect(await store.read.sessionById(session.id)).not.toBeNull() + expect(await store.read.audits()).toEqual([]) + }) + + it("blocks an overlapping mutation until it can read the committed credentials", async () => { + const { prisma, store } = await database() + await store.lock(() => Promise.resolve()) + let entered!: () => void + let release!: () => void + let secondEntered = false + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const first = store.lock(async (records) => { + await records.saveGitHubIntegrationCredentials("first-encrypted-fixture") + entered() + await gate + }) + await started + const second = store.lock(async (records) => { + secondEntered = true + const credentials = await records.githubIntegrationCredentials() + await records.saveGitHubIntegrationCredentials("second-encrypted-fixture") + return credentials.encryptedCredentials + }) + onTestFinished(() => { + release() + }) + + try { + await vi.waitFor( + async () => { + const [waiting] = await prisma.$queryRaw<{ waitEventType: string | null }[]>` + SELECT wait_event_type AS "waitEventType" + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND state = 'active' + AND query LIKE '%identity_store_states%' + AND wait_event_type = 'Lock' + LIMIT 1 + ` + expect(waiting?.waitEventType).toBe("Lock") + }, + { interval: 20, timeout: 5_000 }, + ) + expect(secondEntered).toBe(false) + } finally { + release() + } + + await first + expect(await second).toBe("first-encrypted-fixture") + expect(await store.read.githubIntegrationCredentials()).toMatchObject({ + encryptedCredentials: "second-encrypted-fixture", + }) + }) + + it("links multiple issuer-scoped external identities without allowing a relink", async () => { + 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 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(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("persists installation status, role, and source independently from the user", async () => { + const { store } = await database() + 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.githubIntegrationCredentials()).toMatchObject({ + 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((records) => + records.saveGitHubIntegrationCredentials("rotated-encrypted-credentials"), + ) + + 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("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("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") + 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, + 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.createAuthorizationAttempt(expired)) + await store.lock((records) => records.createAuthorizationAttempt(live)) + + expect( + await prisma.authorizationAttempt.findUnique({ + where: { stateDigest: expired.stateDigest }, + }), + ).toBeNull() + const results = await Promise.all([ + 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 () => { + const { store } = await database() + 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(session.tokenDigest)).toBeNull() + expect( + await store.lock((records) => records.consumeAuthorizationAttempt(attempt.stateDigest)), + ).toBeNull() + }) + + 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 + 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: "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()).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/index.ts b/hub/server/domains/identity/create-store/index.ts new file mode 100644 index 00000000..880a2f9b --- /dev/null +++ b/hub/server/domains/identity/create-store/index.ts @@ -0,0 +1,23 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { withIdentityStoreLock } from "#server/domains/identity/with-store-lock" +import type { IdentityQueries, IdentityStore } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function createIdentityStore(client: PrismaClient): IdentityStore { + const records = createIdentityRecords(client) + const read: IdentityQueries = { + githubIntegrationCredentials: () => records.githubIntegrationCredentials(), + user: (id) => records.user(id), + externalIdentity: (provider, issuer, subject) => + records.externalIdentity(provider, issuer, subject), + membership: (userId) => records.membership(userId), + session: (tokenDigest) => records.session(tokenDigest), + sessionById: (id) => records.sessionById(id), + audits: () => records.audits(), + } + return { + read, + 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..360677d5 --- /dev/null +++ b/hub/server/domains/identity/create-store/types.ts @@ -0,0 +1,84 @@ +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 { GitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/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 { 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 { + githubIntegrationCredentials(): Promise + saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise + user(id: string): Promise + 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): Promise + invalidateFreshness(): Promise + deleteSession(id: string): Promise + deleteSessions(): Promise + createAuthorizationAttempt(input: Omit): Promise + consumeAuthorizationAttempt(stateDigest: string): Promise + audit(input: AuditInput): Promise + audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> +} + +export interface IdentityQueries { + githubIntegrationCredentials(): Promise + user(id: string): Promise + externalIdentity( + provider: string, + issuer: string, + subject: string, + ): Promise<(HubExternalIdentity & { user: HubUser }) | null> + membership(userId: string): Promise + session(tokenDigest: string): Promise + sessionById(id: string): Promise + audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> +} + +export interface IdentityStore { + read: IdentityQueries + lock(operation: (records: IdentityRecords) => Promise): Promise +} diff --git a/hub/server/domains/identity/with-store-lock/index.ts b/hub/server/domains/identity/with-store-lock/index.ts new file mode 100644 index 00000000..42e8665a --- /dev/null +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -0,0 +1,16 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { ensureGitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/ensure-credentials" +import { withIdentityStoreLock as withRepositoryLock } from "#server/repositories/identity-store-state/with-store-lock" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityStoreLock( + client: PrismaClient, + operation: (records: IdentityRecords) => Promise, +): Promise { + return withRepositoryLock(client, async (database) => { + await ensureGitHubIntegrationCredentials(database) + return operation(createIdentityRecords(database)) + }) +} diff --git a/hub/server/integrations/github/decrypt-github-credentials/index.test.ts b/hub/server/integrations/github/decrypt-github-credentials/index.test.ts new file mode 100644 index 00000000..a7fc069f --- /dev/null +++ b/hub/server/integrations/github/decrypt-github-credentials/index.test.ts @@ -0,0 +1,51 @@ +import { createCipheriv } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { decryptGitHubCredentials } from "#server/integrations/github/decrypt-github-credentials" +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import { encryptGitHubCredentials } from "#server/integrations/github/encrypt-github-credentials" +import { GitHubError } from "#server/integrations/github/github-error" + +function encryptPlaintext(key: Buffer, plaintext: string) { + const nonce = Buffer.alloc(12) + const cipher = createCipheriv("aes-256-gcm", key, nonce) + cipher.setAAD(credentialContext) + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) + return Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString("base64") +} + +describe("decryptGitHubCredentials()", () => { + it("preserves credential validation errors after decrypting and parsing credentials", () => { + const key = Buffer.alloc(32) + const encrypted = encryptGitHubCredentials(key, { + appId: "12345", + installationId: "7", + clientId: "", + clientSecret: "synthetic-client-secret", + privateKey: "not-used-after-schema-validation", + }) + + let error: unknown + try { + decryptGitHubCredentials(key, encrypted) + } catch (caught) { + error = caught + } + + expect(error).toBeInstanceOf(GitHubError) + expect(error).toMatchObject({ + code: "invalid_credentials", + message: + "Supply the GitHub App ID, installation ID, client ID, client secret, and RSA private key.", + }) + }) + + it.each([ + ["malformed ciphertext", "not-ciphertext"], + ["malformed JSON", encryptPlaintext(Buffer.alloc(32), "{")], + ])("reports %s as unreadable credentials", (_description, encrypted) => { + expect(() => decryptGitHubCredentials(Buffer.alloc(32), encrypted)).toThrow( + "Cannot decrypt GitHub App credentials. Restore the configured encryption key.", + ) + }) +}) diff --git a/hub/server/integrations/github/decrypt-github-credentials/index.ts b/hub/server/integrations/github/decrypt-github-credentials/index.ts new file mode 100644 index 00000000..a62aeacc --- /dev/null +++ b/hub/server/integrations/github/decrypt-github-credentials/index.ts @@ -0,0 +1,24 @@ +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 + let parsedCredentials: unknown + try { + const data = Buffer.from(encryptedCredentials, "base64") + const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12)) + decipher.setAAD(credentialContext) + decipher.setAuthTag(data.subarray(12, 28)) + const plaintext = Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]) + parsedCredentials = JSON.parse(plaintext.toString("utf8")) + } catch { + throw new Error("Cannot decrypt GitHub App credentials. Restore the configured encryption key.") + } + return validateGitHubCredentials(parsedCredentials) +} diff --git a/hub/server/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/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/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/authentication-providers/create-provider-revision/index.ts b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts new file mode 100644 index 00000000..23cc1a54 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts @@ -0,0 +1,33 @@ +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 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: { enabled: input.enabled }, + }) + if (provider.type !== input.type) { + throw new Error("Authentication provider type cannot change.") + } + 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..669ddec7 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -0,0 +1,48 @@ +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" + +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.$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 > clock_timestamp() AS valid + ` + if (!attempt) return null + if (!attempt.valid) return null + if ( + attempt.purpose !== "sign-in" && + attempt.purpose !== "verify" && + attempt.purpose !== "link-identity" + ) { + throw new Error("Stored authorization purpose is invalid.") + } + 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/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-credentials/constants.ts b/hub/server/repositories/github-integration-credentials/constants.ts new file mode 100644 index 00000000..7d3486c6 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process reads and writes the same GitHub integration credential record. +export const staticGitHubIntegrationCredentialsId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/github-integration-credentials/ensure-credentials/index.ts b/hub/server/repositories/github-integration-credentials/ensure-credentials/index.ts new file mode 100644 index 00000000..0d70f1c9 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/ensure-credentials/index.ts @@ -0,0 +1,11 @@ +import { staticGitHubIntegrationCredentialsId } from "#server/repositories/github-integration-credentials/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function ensureGitHubIntegrationCredentials(database: Prisma.TransactionClient) { + await database.gitHubIntegrationCredentials.upsert({ + where: { id: staticGitHubIntegrationCredentialsId }, + create: { id: staticGitHubIntegrationCredentialsId }, + update: {}, + }) +} diff --git a/hub/server/repositories/github-integration-credentials/get-credentials/index.ts b/hub/server/repositories/github-integration-credentials/get-credentials/index.ts new file mode 100644 index 00000000..8ffdd137 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/get-credentials/index.ts @@ -0,0 +1,9 @@ +import { staticGitHubIntegrationCredentialsId } from "#server/repositories/github-integration-credentials/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getGitHubIntegrationCredentials(database: Prisma.TransactionClient) { + return database.gitHubIntegrationCredentials.findUniqueOrThrow({ + where: { id: staticGitHubIntegrationCredentialsId }, + }) +} diff --git a/hub/server/repositories/github-integration-credentials/save-credentials/index.ts b/hub/server/repositories/github-integration-credentials/save-credentials/index.ts new file mode 100644 index 00000000..df115a67 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/save-credentials/index.ts @@ -0,0 +1,14 @@ +import { staticGitHubIntegrationCredentialsId } from "#server/repositories/github-integration-credentials/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveGitHubIntegrationCredentials( + database: Prisma.TransactionClient, + encryptedCredentials: string, +) { + await database.gitHubIntegrationCredentials.upsert({ + where: { id: staticGitHubIntegrationCredentialsId }, + create: { id: staticGitHubIntegrationCredentialsId, encryptedCredentials }, + update: { encryptedCredentials }, + }) +} diff --git a/hub/server/repositories/github-integration-credentials/types.ts b/hub/server/repositories/github-integration-credentials/types.ts new file mode 100644 index 00000000..4fddf915 --- /dev/null +++ b/hub/server/repositories/github-integration-credentials/types.ts @@ -0,0 +1,3 @@ +export interface GitHubIntegrationCredentials { + encryptedCredentials: string | null +} diff --git a/hub/server/repositories/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/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..ab72e801 --- /dev/null +++ b/hub/server/repositories/security-audits/types.ts @@ -0,0 +1,6 @@ +export interface AuditInput { + event: string + 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 new file mode 100644 index 00000000..8a8e8a5d --- /dev/null +++ b/hub/server/repositories/sessions/create-session/index.ts @@ -0,0 +1,32 @@ +import type { HubSession } from "#server/repositories/sessions/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createSession( + database: Prisma.TransactionClient, + data: Omit, +) { + 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/repositories/sessions/delete-session/index.ts b/hub/server/repositories/sessions/delete-session/index.ts new file mode 100644 index 00000000..439d061e --- /dev/null +++ b/hub/server/repositories/sessions/delete-session/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSession(database: Prisma.TransactionClient, id: string) { + await database.session.deleteMany({ where: { 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..4d7e4df6 --- /dev/null +++ b/hub/server/repositories/sessions/get-session-by-id/index.ts @@ -0,0 +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, + 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 new file mode 100644 index 00000000..746b6750 --- /dev/null +++ b/hub/server/repositories/sessions/get-session/index.ts @@ -0,0 +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, + externalIdentity: true, + authenticationProviderRevision: { include: { authenticationProvider: 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..f49defe5 --- /dev/null +++ b/hub/server/repositories/sessions/types.ts @@ -0,0 +1,9 @@ +export interface HubSession { + id: string + userId: string + externalIdentityId: string + authenticationProviderRevisionId: string + tokenDigest: string + signedInAt: Date + freshAuthorizedAt: Date | null +} 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..2b278df8 --- /dev/null +++ b/hub/server/repositories/sessions/verify-session/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function verifySession( + database: Prisma.TransactionClient, + id: string, + freshAuthorizedAt: Date, +) { + await database.session.update({ + where: { id }, + 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/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/types.ts b/hub/server/repositories/users/types.ts new file mode 100644 index 00000000..50eb5c67 --- /dev/null +++ b/hub/server/repositories/users/types.ts @@ -0,0 +1,7 @@ +export interface HubUser { + id: string + displayName: string | null + avatarUrl: string | null +} + +export type UserInput = Omit 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 } 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/**"] }