From 2724be7b9cbe3e2b914ab56c1710d629a1549872 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 15:58:44 -0700 Subject: [PATCH 01/12] feat(hub): synchronize GitHub authorization Add opt-in GitHub-synchronized admission and prototype baselines while preserving explicit Hub overrides and bounded stale-access behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 6 +- hub/README.md | 4 +- hub/docs/README.md | 3 +- hub/docs/architecture.md | 6 +- hub/docs/database.md | 11 +- hub/docs/github-integration.md | 52 ++ hub/docs/identity.md | 24 +- hub/docs/membership-and-administration.md | 11 +- hub/docs/prototype-authorization.md | 30 +- hub/docs/testing-and-operations.md | 6 +- hub/schema/github-authorization-policy.prisma | 13 + hub/schema/github-repository-source.prisma | 33 + hub/schema/github-synchronized-access.prisma | 28 + hub/schema/github-webhook-delivery.prisma | 21 + .../migration.sql | 110 +++ hub/schema/prototype.prisma | 8 +- hub/schema/security-audit.prisma | 3 + hub/schema/user.prisma | 1 + hub/server/create-app/index.ts | 10 +- .../administration/create-service/index.ts | 2 +- .../complete-github-authorization/index.ts | 121 ++- .../create-admission-policy/index.ts | 18 +- .../authentication/create-runtime/index.ts | 25 +- .../create-service/index.db.test.ts | 2 +- .../create-service/membership.test.ts | 2 +- .../authentication/create-service/types.ts | 11 +- .../create-service/index.db.test.ts | 431 +++++++++ .../create-service/index.ts | 815 ++++++++++++++++++ .../create-service/types.ts | 49 ++ .../map-permission/index.test.ts | 25 + .../map-permission/index.ts | 25 + .../create-service/index.db.test.ts | 29 +- .../create-service/index.ts | 157 +++- .../create-service/types.ts | 9 +- .../evaluate-access/index.test.ts | 43 +- .../evaluate-access/index.ts | 17 +- .../evaluate-access/types.ts | 20 +- hub/server/index.ts | 8 + .../check-repository-admission/constants.ts | 12 + .../security-audits/save-audit/index.ts | 1 + .../repositories/security-audits/types.ts | 2 + .../routes/github-authorization/index.test.ts | 136 +++ .../routes/github-authorization/index.ts | 103 +++ hub/server/routes/index.ts | 33 + hub/server/routes/product/index.ts | 13 + hub/server/routes/types.ts | 2 + 46 files changed, 2359 insertions(+), 132 deletions(-) create mode 100644 hub/docs/github-integration.md create mode 100644 hub/schema/github-authorization-policy.prisma create mode 100644 hub/schema/github-repository-source.prisma create mode 100644 hub/schema/github-synchronized-access.prisma create mode 100644 hub/schema/github-webhook-delivery.prisma create mode 100644 hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql create mode 100644 hub/server/domains/github-authorization/create-service/index.db.test.ts create mode 100644 hub/server/domains/github-authorization/create-service/index.ts create mode 100644 hub/server/domains/github-authorization/create-service/types.ts create mode 100644 hub/server/domains/github-authorization/map-permission/index.test.ts create mode 100644 hub/server/domains/github-authorization/map-permission/index.ts create mode 100644 hub/server/routes/github-authorization/index.test.ts create mode 100644 hub/server/routes/github-authorization/index.ts diff --git a/hub/.env.example b/hub/.env.example index 38890184..ec013451 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -25,7 +25,8 @@ TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres # HUB_GITHUB_AUTH_CLIENT_ID= # HUB_GITHUB_AUTH_CLIENT_SECRET= -# GitHub proves identity by default. Select legacy-repository only for compatibility. +# GitHub proves identity by default. github-synchronized admits users through explicitly +# linked repository sources. legacy-repository preserves the earlier compatibility behavior. HUB_ADMISSION_POLICY=hub-managed # Generate separate host-held values. Bootstrap can enroll the first administrator once. @@ -45,3 +46,6 @@ HUB_GITHUB_CREDENTIAL_PROVIDER=database # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= # GITHUB_PRIVATE_KEY= + +# Required only for /api/github/webhooks. Keep it separate from user-auth credentials. +# HUB_GITHUB_WEBHOOK_SECRET= diff --git a/hub/README.md b/hub/README.md index 84fda01e..c809555a 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,9 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API exposes its health check, GitHub authentication, sessions, and installation membership administration. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. +The API exposes its health check, GitHub authentication, sessions, installation membership administration, prototype authorization, and opt-in GitHub-synchronized admission and prototype baselines. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, invitations, membership roles and lifecycle, administrator recovery, and session revocation are implemented. Prototype authorization, saved prototype data, live updates, product jobs, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, optional GitHub-synchronized authorization with Hub overrides, invitations, membership roles and lifecycle, administrator recovery, session revocation, and prototype metadata are implemented. Artifact ingestion, previews, reviews, product jobs, and deployment are not implemented. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/docs/README.md b/hub/docs/README.md index dc97844e..9581e278 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -8,8 +8,9 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. - [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. +- [GitHub integration](github-integration.md): linked repository sources, reconciliation, webhooks, staleness, and permission mapping. - [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. -- [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, direct roles, explicit deny, and policy precedence. +- [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, Hub overrides, synchronized baselines, and policy precedence. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Background jobs](background-jobs.md): PostgreSQL queues, workers, schedules, setup, and test isolation. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index a3653079..4c94dfe6 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, and Hub-native prototype authorization. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, Hub-native prototype authorization, and opt-in GitHub-synchronized admission and prototype baselines. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [background-job foundation](background-jobs.md) provides PostgreSQL-backed queues and workers without product jobs. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, and one policy evaluator. Deployment has not been built. This app cannot yet find repositories, ingest artifacts, host previews, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [background-job foundation](background-jobs.md) provides PostgreSQL-backed queues and workers without product jobs. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, synchronized GitHub baselines, and one policy evaluator. The [GitHub integration](github-integration.md) stores stable repository source records and reconciles current permissions with installation credentials. Deployment has not been built. This app does not ingest artifacts, host previews, browse source, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Product routes create and read workspaces, prototypes, and immutable versions; archive prototypes; explain effective access; and administer direct access rules. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, and prototype authorization runtime and starts the API at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Product routes create and read workspaces, prototypes, and immutable versions; archive prototypes; choose Hub-managed or GitHub-synchronized prototype authorization; explain effective access; and administer direct access rules. Administration routes link repository sources, opt them into admission, inspect synchronization state, and trigger reconciliation. A verified GitHub App webhook route marks affected sources dirty or removed. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, prototype authorization, and GitHub synchronization runtime, starts the API at a local address, and schedules bounded periodic reconciliation. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. diff --git a/hub/docs/database.md b/hub/docs/database.md index 31f864dc..d835bf9c 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -48,7 +48,16 @@ model Folder { `uuid()` generates UUIDs through Prisma. `@updatedAt` updates timestamps on Prisma writes, not through a database trigger. Raw SQL writers must supply UUIDs and maintain `updated_at` themselves. Use timezone-aware timestamps for stored instants. -Workspace and prototype access use separate grant tables so each user has at most one rule per scope. The database requires an allowed rule to have a role and an explicit deny to have no role. Prototype versions have a unique sequence within their owning prototype and use restrictive deletion so history cannot be detached or silently removed. +Workspace and prototype access use separate grant tables so each user has at most one manual rule per scope. The database requires an allowed rule to have a role and an explicit deny to have no role. Prototype versions have a unique sequence within their owning prototype and use restrictive deletion so history cannot be detached or silently removed. + +GitHub synchronization uses separate records: + +- `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, and synchronization timestamps do not define identity. +- `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. +- `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. +- `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. + +`Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. ## Shared client diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md new file mode 100644 index 00000000..f3455675 --- /dev/null +++ b/hub/docs/github-integration.md @@ -0,0 +1,52 @@ +# GitHub integration + +GitHub-synchronized authorization uses repository permissions as an opt-in baseline below Hub overrides. It is not repository-gated authorization: Hub links the source, chooses where synchronization applies, maps the permission, enforces staleness, and makes the final admission and prototype decisions. + +## Source identity and credentials + +A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, active or removed state, admission switch, synchronization status, and timestamps. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. + +All permission checks use an installation token created from the configured GitHub source-integration credentials. GitHub user authorization proves identity only. Hub does not retain or reuse the user's transient token for synchronization. + +## Opt-in policies + +`HUB_ADMISSION_POLICY=hub-managed` is the default. Select `github-synchronized` to permit linked sources to participate, then enable admission on each intended repository through the administration API. No linked or opted-in source means no synchronized admission. + +Each prototype separately selects `hub-managed` or `github-synchronized`. The synchronized mode also requires one active repository source. Unlinking a source removes its synchronized baselines and leaves the prototype without a usable baseline until an administrator selects another source or returns it to Hub-managed mode. + +`legacy-repository` is a compatibility policy for current Replica behavior. It scans installation repositories during sign-in and is intentionally not the default or the normal synchronized source model. + +## Permission mapping + +| Raw GitHub permission | Admission | Prototype baseline | +| --- | --- | --- | +| `admin`, `maintain`, `write` | Member | `editor` | +| `triage` | Member | `commenter` | +| `read` | Member | `viewer` | +| `none` or missing | Denied | None | + +GitHub does not map to Hub installation administrator or prototype administrator. Explicit Hub deny wins first, followed by explicit prototype and workspace roles, then the synchronized baseline. + +## Reconciliation and webhooks + +`POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. + +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository or installation removal marks synchronized records removed. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. + +## Staleness and outages + +A successful confirmation records `lastConfirmedAt`. Failed provider calls record a bounded failure status and leave that time unchanged. Hub permits a last-known-good baseline for one day, then fails closed. Reconciliation also removes synchronized memberships and sessions when no fresh admission-eligible source remains. + +Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. + +## Administration API + +- `GET /api/administration/github/repositories` +- `POST /api/administration/github/repositories` +- `PATCH /api/administration/github/repositories/:repositorySourceId` +- `DELETE /api/administration/github/repositories/:repositorySourceId` +- `POST /api/administration/github/repositories/:repositorySourceId/reconcile` +- `PUT /api/prototypes/:prototypeId/authorization` +- `GET /api/prototypes/:prototypeId/access/explanation` + +Administration mutations require an active installation administrator, fresh verification, the configured origin, and matching CSRF cookie and header. Product policy changes require a fresh effective prototype administrator. Request bodies are omitted from logs, responses are private and non-cacheable, and mutations write structured audits in the same transaction. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 80ca1448..f2507c05 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,6 +1,6 @@ # Hub identity -Hub uses GitHub user authorization to prove an external identity. Hub then applies its own installation admission and membership rules before creating a provider-neutral browser session. Repository permissions do not grant installation membership. +Hub uses GitHub user authorization to prove an external identity. Hub then applies its configured installation admission and membership rules before creating a provider-neutral browser session. Repository permissions grant installation membership only when an operator explicitly selects GitHub-synchronized admission and opts in at least one linked repository. ## GitHub user authentication @@ -10,9 +10,13 @@ Hub stores a versioned authentication-provider revision with non-secret configur The callback consumes an attempt once, checks the same-browser binding and current provider and revocation state, exchanges the code with PKCE, and reads the authenticated GitHub profile. Hub maps GitHub's immutable numeric account ID to `ExternalIdentity.subject`. The user access token exists only while requesting that profile; Hub never stores it. -Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. +Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. Restoring a session for a `synchronized` membership also requires at least one fresh admission-eligible linked baseline, so expired access fails closed immediately rather than waiting for the periodic sweep. -`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to create a member assignment after repository admission. Repository admission requires explicit collaboration on one of the first 100 private installed repositories and has a ten-second total deadline. Installations that exceed that bound must use Hub-managed admission or a later synchronized authorization policy. The GitHub authentication adapter never reads installation credentials or performs repository authorization. +`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. + +`github-synchronized` is opt-in. It checks only repository source records whose admission switch is enabled, resolves their current metadata by immutable GitHub repository ID, uses the separately configured GitHub App installation credentials, and records the confirmed raw permission and mapped baseline separately from the membership. A qualifying baseline creates or refreshes only a `synchronized` member assignment. Manual, invitation, bootstrap, recovery, suspended, and administratively removed assignments remain Hub overrides and are never rewritten by synchronization. Permission loss removes a synchronized membership and its sessions only after reconciliation or the bounded last-known-good window expires. + +`legacy-repository` preserves the previous compatibility behavior. It checks explicit collaboration on one of the first 100 private installed repositories within a ten-second total deadline and creates a `legacy-synchronized` member assignment, without requiring linked source records. Installations beyond that bound must use Hub-managed or GitHub-synchronized admission. The migration backfills pre-existing synchronized compatibility memberships to that source before linked-source synchronization begins. Linked-source reconciliation does not manage those compatibility assignments. It is not the normal synchronized authorization mode. The GitHub authentication adapter itself never reads installation credentials or performs repository authorization. Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. @@ -27,7 +31,7 @@ Sign-out revokes the current database session. Every restored session requires a - `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 and product context. Audit records describe changes but do not grant access. -Queries are available without a lock. Mutations run through one database transaction that locks a fixed store-state row. Related membership, invitation, session, and audit changes therefore commit or roll back together. Overlapping final-administrator changes and invitation activations observe committed state. +Store operations can share one database transaction and lock a fixed store-state row. Related membership, invitation, session, and audit changes therefore commit or roll back together. Overlapping final-administrator changes and invitation activations observe committed state. ## Installation admission and administration @@ -37,9 +41,7 @@ Administrators invite an identity by provider, issuer, and immutable subject. Mu Administrator mutations require an active administrator session verified less than five minutes ago. Hub prevents concurrent demotion, suspension, or removal from eliminating the final active administrator. See [Installation membership and administration](membership-and-administration.md). -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-integration credentials GitHub source access is an optional integration boundary, not the global identity boundary. Set `HUB_GITHUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): @@ -48,12 +50,12 @@ GitHub source access is an optional integration boundary, not the global identit | `database` (default) | Generate `HUB_GITHUB_CREDENTIAL_KEY` with `openssl rand -hex 32`. The provider encrypts GitHub integration credentials before storage and authenticates their context when reading. | | `environment` | Set every `GITHUB_*` value, including `GITHUB_INSTALLATION_ID`. This provider is read-only and ignores database credentials. | -Both providers currently validate an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. A deployment may use the same GitHub App for user authorization and source integration, but it supplies and rotates the two configurations independently. +Both providers currently validate an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. A deployment may use the same GitHub App for user authorization and source integration, but it supplies and rotates the two configurations independently. Set `HUB_GITHUB_WEBHOOK_SECRET` separately when enabling the webhook route. -Database credentials use AES-256-GCM with contextual additional authenticated data. Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors. Hub never falls back to another provider. +Database credentials use AES-256-GCM with contextual additional authenticated data. Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors; Hub never falls back to another provider. ## Current scope -This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary uses these sessions without treating membership or repository access as product authority. Identity does not add GitHub membership synchronization, artifact ingestion, production OIDC, account linking, or SCIM. +This layer adds GitHub sign-in, Hub-managed and opt-in GitHub-synchronized installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary may consume a synchronized repository baseline without treating installation membership as product authority. Identity does not add artifact ingestion, production OIDC, account linking, or SCIM. -Apply migrations with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. +Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index 166c45bc..2a421ddd 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -1,6 +1,6 @@ # Installation membership and administration -Hub owns installation admission after an authentication provider proves an identity. Repository access and installation membership are separate decisions. +Hub owns installation admission after an authentication provider proves an identity. Repository access affects installation membership only through the explicit GitHub-synchronized policy; Hub remains the final evaluator. ## Membership contract @@ -11,10 +11,12 @@ Each Hub user has at most one installation membership. | Active member | May create and restore a Hub session. Product access remains a separate future decision. | | Active administrator | Has member access and may use installation administration after fresh identity verification. Existing product access remains separate. | | Suspended | Cannot create or use a Hub session. An administrator may restore the membership. | -| Removed | Cannot create or use a Hub session. The retained record preserves audit linkage, and return requires a new invitation. | +| Removed | Cannot create or use a Hub session. The retained record preserves audit linkage. Manual removals require a new invitation; synchronized removals can return only after a new qualifying confirmation. | Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. +Synchronization creates and refreshes only memberships whose source is `synchronized`. It never reactivates or rewrites a manual, invitation, bootstrap, recovery, or suspended membership. When no fresh admission-eligible baseline remains, reconciliation marks the synchronized membership removed and deletes its sessions in the same transaction. + Suspension and removal also check Hub-managed product administration in the same transaction. The change fails with `product_administration_orphaned` until another active user can administer every affected workspace and prototype. A role change from installation administrator to member does not remove explicit workspace or prototype roles. ## Invitations @@ -48,6 +50,9 @@ The minimal administration surface uses: - `POST /api/administration/invitations` - `DELETE /api/administration/invitations/:id` - `PATCH /api/administration/memberships/:id` +- `GET` and `POST /api/administration/github/repositories` +- `PATCH` and `DELETE /api/administration/github/repositories/:repositorySourceId` +- `POST /api/administration/github/repositories/:repositorySourceId/reconcile` - `POST /api/auth/bootstrap` - `POST /api/auth/recovery` @@ -61,4 +66,4 @@ Membership operations write a structured security record with a bounded action, ## Non-goals -This boundary does not grant prototype, repository, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines Hub-managed product access. Membership does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. +This boundary does not grant direct prototype, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines product access and can consume a repository baseline below Hub overrides. Membership synchronization does not browse source, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 83c7bf01..ee0131f7 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -1,6 +1,6 @@ # Prototype authorization -Hub owns prototype access after authentication and installation admission. A user does not need repository access to use a Hub-managed prototype, and GitHub repository permissions do not grant Hub access. +Hub owns prototype access after authentication and installation admission. A user does not need repository access to use a Hub-managed prototype. A prototype can explicitly select a linked GitHub repository as a synchronized baseline, but Hub remains the final evaluator. ## Product records @@ -25,19 +25,32 @@ Artifact payloads are not part of this layer. A version identifies immutable pro Installation membership and installation administrator status do not grant access to existing workspaces or prototypes. The workspace-creation rule is the only installation-administrator product rule in this layer. After creation, the user's explicit workspace grant supplies authority. -## Direct access and precedence +## Hub overrides, synchronized baselines, and precedence Hub stores one direct workspace rule and one direct prototype rule per user and scope. A rule is either an explicit deny or an allowed role. Removing a rule restores evaluation from the remaining sources. +Each prototype starts in `hub-managed` mode. An administrator may explicitly select `github-synchronized` mode and one active linked repository source. There is no implicit fallback from missing Hub access to GitHub. + The evaluator applies this order: 1. An explicit Hub deny at either the workspace or prototype scope denies access. 2. An explicit prototype role applies, including a lower role that intentionally restricts a stronger workspace role. 3. An explicit workspace role applies. -4. A future optional synchronized baseline may apply. +4. A fresh GitHub-synchronized baseline may apply. 5. Otherwise Hub denies access. -Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. Hub consults an optional baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing accepts that baseline policy source and checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. GitHub baseline decisions also report the repository source, immutable GitHub repository ID, raw permission, last confirmation time, and synchronization status. Routes use this evaluator instead of reimplementing precedence. Hub consults the synchronized baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. + +The fixed mapping is: + +| GitHub permission | Hub prototype baseline | +| --- | --- | +| `admin`, `maintain`, `write` | `editor` | +| `triage` | `commenter` | +| `read` | `viewer` | +| `none` or missing collaborator | No access | + +GitHub never supplies Hub `administrator` authority. Manual workspace and prototype grants and denies live in separate tables and synchronization does not update or delete them. Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. @@ -55,6 +68,7 @@ The product API uses: - `GET` and `POST /api/workspaces/:workspaceId/prototypes` - `GET /api/prototypes/:prototypeId` - `POST /api/prototypes/:prototypeId/archive` +- `PUT /api/prototypes/:prototypeId/authorization` - `GET /api/prototypes/:prototypeId/access/explanation` - `PUT` and `DELETE /api/prototypes/:prototypeId/access/:userId` - `GET` and `POST /api/prototypes/:prototypeId/versions` @@ -70,6 +84,10 @@ Workspace creation, prototype creation and archival, version creation, role gran Records identify the actor, session, authentication-provider revision, affected product records, target user when applicable, outcome, and a bounded reason. They do not include cookies, provider tokens, host secrets, request bodies, prototype artifacts, or mutable identity labels. -## Deferred policy sources +## Synchronization lifecycle + +The [GitHub integration](github-integration.md) confirms permissions with installation credentials. Webhooks only mark relevant sources dirty or removed; periodic or manual reconciliation repairs missed events and confirms current permissions. A failed provider call does not refresh confirmation. Baselines remain usable only inside the configured one-day last-known-good window and fail closed afterward. + +Repository rename changes mutable owner, name, and default-branch fields without changing the immutable source identity. Repository removal, installation removal, and permission loss update synchronized baselines only. Direct Hub overrides remain intact. -GitHub repository synchronization, webhooks, reconciliation, and repository-to-role mapping are not implemented. They may later produce an optional baseline role without changing Hub's direct-rule precedence. Source-provider mutations, public links, artifact ingestion, promotion, retention, and rollback remain separate product boundaries. +Source mutation, public links, artifact ingestion, promotion, retention, and rollback remain separate product boundaries. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 83765b5b..ac03780a 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,11 @@ The database command uses `--passWithNoTests` so an empty selection exits succes A dedicated migration case applies the foundation migration, seeds retained lifecycle and security records, then applies the next migration. This catches upgrade behavior that an empty final schema cannot prove, including audit-outcome backfills and lifecycle constraints. -Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. + +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, repository rename and removal, installation removal, permission loss, provider outage, dirty webhooks, replayed deliveries, missed-event repair, manual override preservation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. + +The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. diff --git a/hub/schema/github-authorization-policy.prisma b/hub/schema/github-authorization-policy.prisma new file mode 100644 index 00000000..3683cedb --- /dev/null +++ b/hub/schema/github-authorization-policy.prisma @@ -0,0 +1,13 @@ +model GitHubAuthorizationPolicy { + // ID columns + id String @id @db.Uuid + + // Attribute columns + lastKnownGoodSeconds Int @default(86400) @map("last_known_good_seconds") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("github_authorization_policies") +} diff --git a/hub/schema/github-repository-source.prisma b/hub/schema/github-repository-source.prisma new file mode 100644 index 00000000..cda1b3ef --- /dev/null +++ b/hub/schema/github-repository-source.prisma @@ -0,0 +1,33 @@ +model GitHubRepositorySource { + // ID columns + id String @id @default(uuid()) @db.Uuid + githubRepositoryId String @unique @map("github_repository_id") + + // Relations + prototypes Prototype[] + synchronizedAccess GitHubSynchronizedAccess[] + webhookDeliveries GitHubWebhookDelivery[] + auditTargets SecurityAudit[] + + // Attribute columns + appId String @map("app_id") + installationId String @map("installation_id") + owner String + name String + defaultBranch String @map("default_branch") + status String @default("active") + admissionEnabled Boolean @default(false) @map("admission_enabled") + synchronizationStatus String @default("dirty") @map("synchronization_status") + synchronizationError String? @map("synchronization_error") + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + dirtyAt DateTime? @map("dirty_at") @db.Timestamptz(3) + lastAttemptedAt DateTime? @map("last_attempted_at") @db.Timestamptz(3) + lastConfirmedAt DateTime? @map("last_confirmed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([installationId, status]) + @@map("github_repository_sources") +} diff --git a/hub/schema/github-synchronized-access.prisma b/hub/schema/github-synchronized-access.prisma new file mode 100644 index 00000000..44fe6e8c --- /dev/null +++ b/hub/schema/github-synchronized-access.prisma @@ -0,0 +1,28 @@ +model GitHubSynchronizedAccess { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String @map("repository_source_id") @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource @relation(fields: [repositorySourceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + // Attribute columns + rawPermission String @map("raw_permission") + mappedPrototypeRole PrototypeRole? @map("mapped_prototype_role") + admissionEligible Boolean @map("admission_eligible") + status String @default("active") + source String @default("github_app_installation") + synchronizationStatus String @default("current") @map("synchronization_status") + lastConfirmedAt DateTime @map("last_confirmed_at") @db.Timestamptz(3) + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([repositorySourceId, userId]) + @@index([userId, status]) + @@map("github_synchronized_access") +} diff --git a/hub/schema/github-webhook-delivery.prisma b/hub/schema/github-webhook-delivery.prisma new file mode 100644 index 00000000..39f1316c --- /dev/null +++ b/hub/schema/github-webhook-delivery.prisma @@ -0,0 +1,21 @@ +model GitHubWebhookDelivery { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String? @map("repository_source_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource? @relation(fields: [repositorySourceId], references: [id], onDelete: SetNull) + + // Attribute columns + deliveryId String @unique @map("delivery_id") + event String + action String? + installationId String @map("installation_id") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([installationId, createdAt]) + @@map("github_webhook_deliveries") +} diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql new file mode 100644 index 00000000..1d328d76 --- /dev/null +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -0,0 +1,110 @@ +-- CreateTable +CREATE TABLE "github_authorization_policies" ( + "id" UUID NOT NULL, + "last_known_good_seconds" INTEGER NOT NULL DEFAULT 86400, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_authorization_policies_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_authorization_policies_last_known_good_seconds" CHECK ( + "last_known_good_seconds" BETWEEN 0 AND 604800 + ) +); + +INSERT INTO "github_authorization_policies" ("id") +VALUES ('00000000-0000-0000-0000-000000000001'); + +-- CreateTable +CREATE TABLE "github_repository_sources" ( + "id" UUID NOT NULL, + "github_repository_id" TEXT NOT NULL, + "app_id" TEXT NOT NULL, + "installation_id" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "name" TEXT NOT NULL, + "default_branch" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "admission_enabled" BOOLEAN NOT NULL DEFAULT false, + "synchronization_status" TEXT NOT NULL DEFAULT 'dirty', + "synchronization_error" TEXT, + "removed_at" TIMESTAMPTZ(3), + "dirty_at" TIMESTAMPTZ(3), + "last_attempted_at" TIMESTAMPTZ(3), + "last_confirmed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_repository_sources_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_repository_sources_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_repository_sources_synchronization_status" CHECK ( + "synchronization_status" IN ('dirty', 'current', 'failed', 'removed') + ) +); + +-- CreateTable +CREATE TABLE "github_synchronized_access" ( + "id" UUID NOT NULL, + "repository_source_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "raw_permission" TEXT NOT NULL, + "mapped_prototype_role" "PrototypeRole", + "admission_eligible" BOOLEAN NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "source" TEXT NOT NULL DEFAULT 'github_app_installation', + "synchronization_status" TEXT NOT NULL DEFAULT 'current', + "last_confirmed_at" TIMESTAMPTZ(3) NOT NULL, + "removed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_synchronized_access_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_synchronized_access_raw_permission" CHECK ( + "raw_permission" IN ('admin', 'maintain', 'write', 'triage', 'read', 'none') + ), + CONSTRAINT "github_synchronized_access_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_synchronized_access_synchronization_status" CHECK ( + "synchronization_status" IN ('current', 'dirty', 'failed', 'removed') + ) +); + +-- CreateTable +CREATE TABLE "github_webhook_deliveries" ( + "id" UUID NOT NULL, + "repository_source_id" UUID, + "delivery_id" TEXT NOT NULL, + "event" TEXT NOT NULL, + "action" TEXT, + "installation_id" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_webhook_deliveries_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "prototypes" +ADD COLUMN "authorization_mode" TEXT NOT NULL DEFAULT 'hub-managed', +ADD COLUMN "source_repository_id" UUID, +ADD CONSTRAINT "prototypes_authorization_mode" CHECK ( + "authorization_mode" IN ('hub-managed', 'github-synchronized') +); + +ALTER TABLE "security_audits" +ADD COLUMN "github_repository_source_id" UUID; + +-- CreateIndex +CREATE UNIQUE INDEX "github_repository_sources_github_repository_id_key" ON "github_repository_sources"("github_repository_id"); +CREATE INDEX "github_repository_sources_installation_id_status_idx" ON "github_repository_sources"("installation_id", "status"); +CREATE UNIQUE INDEX "github_synchronized_access_repository_source_id_user_id_key" ON "github_synchronized_access"("repository_source_id", "user_id"); +CREATE INDEX "github_synchronized_access_user_id_status_idx" ON "github_synchronized_access"("user_id", "status"); +CREATE UNIQUE INDEX "github_webhook_deliveries_delivery_id_key" ON "github_webhook_deliveries"("delivery_id"); +CREATE INDEX "github_webhook_deliveries_installation_id_created_at_idx" ON "github_webhook_deliveries"("installation_id", "created_at"); +CREATE INDEX "prototypes_source_repository_id_idx" ON "prototypes"("source_repository_id"); +CREATE INDEX "security_audits_github_repository_source_id_idx" ON "security_audits"("github_repository_source_id"); + +-- AddForeignKey +ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "github_webhook_deliveries" ADD CONSTRAINT "github_webhook_deliveries_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_source_repository_id_fkey" FOREIGN KEY ("source_repository_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_github_repository_source_id_fkey" FOREIGN KEY ("github_repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/schema/prototype.prisma b/hub/schema/prototype.prisma index fd2bdfd2..5064e50a 100644 --- a/hub/schema/prototype.prisma +++ b/hub/schema/prototype.prisma @@ -9,10 +9,13 @@ model Prototype { createdBy User @relation("PrototypeCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) versions PrototypeVersion[] grants PrototypeAccessGrant[] + sourceRepository GitHubRepositorySource? @relation(fields: [sourceRepositoryId], references: [id], onDelete: SetNull) // Attribute columns - name String - archivedAt DateTime? @map("archived_at") @db.Timestamptz(3) + name String + authorizationMode String @default("hub-managed") @map("authorization_mode") + sourceRepositoryId String? @map("source_repository_id") @db.Uuid + archivedAt DateTime? @map("archived_at") @db.Timestamptz(3) // Timestamp columns createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) @@ -20,5 +23,6 @@ model Prototype { @@index([workspaceId]) @@index([createdByUserId]) + @@index([sourceRepositoryId]) @@map("prototypes") } diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index 312de7fc..ed0b61c4 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -10,10 +10,12 @@ model SecurityAudit { prototypeId String? @map("prototype_id") @db.Uuid prototypeVersionId String? @map("prototype_version_id") @db.Uuid targetUserId String? @map("target_user_id") @db.Uuid + githubRepositorySourceId String? @map("github_repository_source_id") @db.Uuid // Relations targetMembership InstallationMembership? @relation("TargetMembership", fields: [targetMembershipId], references: [id], onDelete: SetNull) invitation MembershipInvitation? @relation(fields: [invitationId], references: [id], onDelete: SetNull) + githubRepositorySource GitHubRepositorySource? @relation(fields: [githubRepositorySourceId], references: [id], onDelete: SetNull) // Attribute columns event String @@ -31,5 +33,6 @@ model SecurityAudit { @@index([prototypeId]) @@index([prototypeVersionId]) @@index([targetUserId]) + @@index([githubRepositorySourceId]) @@map("security_audits") } diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma index 3e9abf4f..0280e775 100644 --- a/hub/schema/user.prisma +++ b/hub/schema/user.prisma @@ -12,6 +12,7 @@ model User { createdVersions PrototypeVersion[] @relation("PrototypeVersionCreator") workspaceGrants WorkspaceAccessGrant[] prototypeGrants PrototypeAccessGrant[] + githubSynchronizedAccess GitHubSynchronizedAccess[] // Attribute columns displayName String? @map("display_name") diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index dcdb588d..22872cd7 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -18,9 +18,17 @@ export function createApp({ app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) app.use( - ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], + [ + "/api/auth", + "/api/session", + "/api/administration", + "/api/github/webhooks", + "/api/workspaces", + "/api/prototypes", + ], omitRequestLogPayload, ) + app.use("/api/github/webhooks", express.raw({ type: "application/json", limit: "1mb" })) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index bb38952c..4ab0b945 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -46,7 +46,7 @@ async function getAdministrationSession( github: null, configuration: null, providerRevisionId: null, - admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(null) }, now: dependencies.now, }, sessionToken, diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index 398959a1..340419fe 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -45,52 +45,45 @@ export async function completeGitHubAuthorization( "The authorization request is invalid.", ) } - const authorizationAttempt = attempt const purpose: AuthorizationPurpose = attempt.purpose - let policyAdmitted = false - async function reportProviderFailure(error: unknown): Promise { + let profile: GitHubUserProfile + let admissionBaseline: Awaited> = null + let admissionFailure: AuthenticationError | null = null + try { + profile = await github.identify(code, attempt.pkceVerifier) + } catch { await store.lock((records) => records.audit({ event: "authentication.provider_failed", outcome: "failed", actorUserId: null, - sessionId: authorizationAttempt.originatingSessionId, + sessionId: attempt.originatingSessionId, authenticationProviderRevisionId: providerRevisionId, }), ) - if (error instanceof AuthenticationError) throw error throw new AuthenticationError( 503, "authentication_provider_failed", "The authentication provider failed.", ) } - const profile: GitHubUserProfile = await github - .identify(code, authorizationAttempt.pkceVerifier) - .catch(reportProviderFailure) - if (authorizationAttempt.purpose === "sign-in") { - const policyNeeded = await store.lock(async (records) => { - const identity = await records.externalIdentity( - "github", - configuration.issuer, - profile.subject, - ) - const membership = identity ? await records.membership(identity.userId) : null - const invitation = - !membership || membership.status === "removed" - ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) - : null - return !membership && !invitation - }) - if (policyNeeded) { - try { - policyAdmitted = await admissionPolicy.admit(profile) - } catch (error) { - await reportProviderFailure(error) + if (attempt.purpose === "sign-in") { + try { + const admission = await admissionPolicy.admit(profile) + admissionBaseline = admission === true ? "legacy" : admission || null + } catch (error) { + if (error instanceof AuthenticationError) { + admissionFailure = error + } else { + admissionFailure = new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) } } } - const result = await store.lock(async (records) => { + const result = await store.lock(async (records, transaction) => { const [currentProvider, stateRecord] = await Promise.all([ records.currentAuthenticationProviderRevision("github"), records.identityStoreState(), @@ -129,15 +122,55 @@ export async function completeGitHubAuthorization( } let identity = await records.externalIdentity("github", configuration.issuer, profile.subject) let membership = identity ? await records.membership(identity.userId) : null + const synchronizableMembership = + admissionBaseline !== null && + membership?.status !== "suspended" && + (membership === null || + (membership.source === "synchronized" && membership.status === "removed")) const invitation = purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) : null + if ( + purpose === "sign-in" && + admissionFailure && + !(membership?.status === "active" && membership.source !== "synchronized") && + !invitation + ) { + await records.audit({ + event: "authentication.admission_failed", + outcome: "failed", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: admissionFailure.code }, + }) + return { failure: admissionFailure, purpose } + } + if ( + purpose === "sign-in" && + membership?.status === "active" && + membership.source === "synchronized" && + admissionPolicy.name === "github-synchronized" && + !admissionBaseline + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) + return { denied: true as const } + } if ( purpose === "sign-in" && membership?.status !== "active" && !invitation && - !(policyAdmitted && !membership) + !synchronizableMembership ) { await records.audit({ event: "authentication.admission_denied", @@ -271,24 +304,28 @@ export async function completeGitHubAuthorization( targetMembershipId: membership.id, invitationId: invitation.id, }) - } else if (policyAdmitted && !membership) { + } else if (synchronizableMembership) { membership = await records.saveMembership({ userId: identity.userId, status: "active", role: "member", - source: "synchronized", + source: admissionBaseline === "legacy" ? "legacy-synchronized" : "synchronized", suspendedAt: null, removedAt: null, }) - await records.audit({ - event: "membership.synchronize", - outcome: "succeeded", - actorUserId: identity.userId, - sessionId: null, - authenticationProviderRevisionId: providerRevisionId, - targetMembershipId: membership.id, - context: { source: "legacy_repository" }, - }) + } + } + if (admissionBaseline && admissionBaseline !== "legacy") { + try { + await admissionPolicy.saveBaseline?.(transaction, identity.userId, admissionBaseline) + } catch (error) { + if (!( + error instanceof AuthenticationError && + error.code === "github_authorization_changed" && + membership?.source !== "synchronized" + )) { + throw error + } } } const sessionToken = randomBytes(32).toString("base64url") @@ -312,5 +349,9 @@ export async function completeGitHubAuthorization( if ("denied" in result && result.denied) { throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") } + if ("failure" in result) { + if (result.failure instanceof Error) throw result.failure + throw new AuthenticationError(503, "admission_failed", "Admission failed.") + } return result } diff --git a/hub/server/domains/authentication/create-admission-policy/index.ts b/hub/server/domains/authentication/create-admission-policy/index.ts index d7ced923..f9869fae 100644 --- a/hub/server/domains/authentication/create-admission-policy/index.ts +++ b/hub/server/domains/authentication/create-admission-policy/index.ts @@ -1,19 +1,31 @@ import type { AdmissionPolicy } from "#server/domains/authentication/create-service/types" import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" import { checkRepositoryAdmission } from "#server/integrations/github/check-repository-admission" import { AuthenticationError } from "#server/utils/authentication-error" export function createAdmissionPolicy( environment: NodeJS.ProcessEnv, store: IdentityStore, + githubAuthorization: GitHubAuthorizationService, ): AdmissionPolicy { const selected = environment.HUB_ADMISSION_POLICY ?? "hub-managed" if (selected === "hub-managed") { - return { name: "hub-managed", admit: () => Promise.resolve(false) } + return { name: "hub-managed", admit: () => Promise.resolve(null) } + } + if (selected === "github-synchronized") { + return { + name: "github-synchronized", + admit: (profile) => githubAuthorization.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + githubAuthorization.saveAdmissionBaseline(database, userId, baseline), + } } if (selected !== "legacy-repository") { - throw new Error("HUB_ADMISSION_POLICY must be hub-managed or legacy-repository.") + throw new Error( + "HUB_ADMISSION_POLICY must be hub-managed, github-synchronized, or legacy-repository.", + ) } const provider = createGitHubIntegrationCredentialProvider(environment) return { @@ -28,7 +40,7 @@ export function createAdmissionPolicy( "GitHub source integration is not configured.", ) } - return await checkRepositoryAdmission(credentials, profile) + return (await checkRepositoryAdmission(credentials, profile)) ? "legacy" : null } catch (error) { if (error instanceof AuthenticationError) throw error throw new AuthenticationError( diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 5084467e..8a1976ae 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -5,6 +5,7 @@ import type { AuthenticationSettings } from "#server/domains/authentication/crea import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" @@ -66,11 +67,15 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv const origin = readOrigin(environment) const configuration = readGitHubAuthentication(environment, origin) const store = createIdentityStore(prisma) - const admissionPolicy = createAdmissionPolicy(environment, store) - const trustedProxyHops = readTrustedProxyHops(environment) + const githubAuthorization = createGitHubAuthorizationService({ + database: prisma, + environment, + store, + }) const providerRevision = configuration ? await ensureGitHubProviderRevision(store, configuration) : null + const admissionPolicy = createAdmissionPolicy(environment, store, githubAuthorization) const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") if (bootstrapSecret && recoverySecret && bootstrapSecret === recoverySecret) { @@ -81,11 +86,23 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv secureCookies: origin.protocol === "https:", configured: configuration !== null, admissionPolicy: admissionPolicy.name, - trustedProxyHops, + trustedProxyHops: readTrustedProxyHops(environment), } return { administration: createAdministrationService({ store }), - prototypeAuthorization: createPrototypeAuthorizationService({ database: prisma, store }), + githubAuthorization, + prototypeAuthorization: createPrototypeAuthorizationService({ + baseline: { + baseline: (database, userId, prototypeId) => + githubAuthorization.prototypeBaseline(database, userId, prototypeId), + workspaceIds: (database, userId) => + githubAuthorization.prototypeWorkspaceIds(database, userId), + prototypeIds: (database, userId, workspaceId) => + githubAuthorization.prototypeIds(database, userId, workspaceId), + }, + database: prisma, + store, + }), service: createAuthenticationService({ store, github: configuration ? createGitHubUserAuthClient(configuration) : null, diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 3419bc1d..5ceb436a 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -449,7 +449,7 @@ describe("createAuthenticationService() database behavior", () => { github: null, configuration: null, providerRevisionId: null, - admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(true) }, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve("legacy") }, }) const results = await Promise.allSettled([ diff --git a/hub/server/domains/authentication/create-service/membership.test.ts b/hub/server/domains/authentication/create-service/membership.test.ts index 720d7e4d..ec938477 100644 --- a/hub/server/domains/authentication/create-service/membership.test.ts +++ b/hub/server/domains/authentication/create-service/membership.test.ts @@ -65,7 +65,7 @@ describe("createAuthenticationService() membership enforcement", () => { providerRevisionId: null, admissionPolicy: { name: "hub-managed", - admit: () => Promise.resolve(false), + admit: () => Promise.resolve(null), }, }) diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts index cfd6b3c6..7c905fe5 100644 --- a/hub/server/domains/authentication/create-service/types.ts +++ b/hub/server/domains/authentication/create-service/types.ts @@ -5,12 +5,19 @@ import type { GitHubUserProfile, } from "#server/integrations/github/create-user-auth-client/types" import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" +import type { GitHubAdmissionBaseline } from "#server/domains/github-authorization/create-service/types" +import type { Prisma } from "../../../generated/prisma/client.ts" export type AuthorizationPurpose = "sign-in" | "verify" | "bootstrap" | "recovery" export interface AdmissionPolicy { - name: "hub-managed" | "legacy-repository" - admit(profile: GitHubUserProfile): Promise + name: "hub-managed" | "github-synchronized" | "legacy-repository" + admit(profile: GitHubUserProfile): Promise + saveBaseline?( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + ): Promise } export interface AuthenticationSettings { diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts new file mode 100644 index 00000000..29fdede5 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -0,0 +1,431 @@ +import { createHmac, generateKeyPairSync } from "node:crypto" +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +function environment() { + const privateKey = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey + return { + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", + HUB_GITHUB_WEBHOOK_SECRET: "synthetic-webhook-secret", + GITHUB_APP_ID: "12345", + GITHUB_INSTALLATION_ID: "7", + GITHUB_CLIENT_ID: "test-client", + GITHUB_CLIENT_SECRET: "synthetic-client-secret", + GITHUB_PRIVATE_KEY: privateKey, + } +} + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + const store = createIdentityStore(prisma) + return { + prisma, + store, + service: createGitHubAuthorizationService({ + database: prisma, + environment: environment(), + store, + }), + } +} + +async function createUser( + store: IdentityStore, + { + subject, + membershipSource = "manual", + token, + }: { + subject: string + membershipSource?: "manual" | "synchronized" + token?: string + }, +) { + return store.lock(async (records) => { + const user = await records.createUser({ displayName: `User ${subject}`, avatarUrl: null }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `user-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source: membershipSource, + }) + const session = token + ? await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }) + : null + return { identity, membership, session, user } + }) +} + +function webhook(body: object, event: string, deliveryId: string) { + const encoded = Buffer.from(JSON.stringify(body)) + return { + body: encoded, + deliveryId, + event, + signature: `sha256=${createHmac("sha256", "synthetic-webhook-secret") + .update(encoded) + .digest("hex")}`, + } +} + +describe("createGitHubAuthorizationService() database behavior", () => { + it("uses only a fresh baseline for the linked repository and keeps repositories isolated", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const [source, otherSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "other", + name: "repository", + defaultBranch: "main", + }, + }), + ]) + const request = vi.fn() + vi.stubGlobal("fetch", request) + + await expect( + service.evaluateAdmission({ + subject: "42", + username: "user-42", + displayName: "User 42", + avatarUrl: null, + }), + ).resolves.toBeNull() + expect(request).not.toHaveBeenCalled() + await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { admissionEnabled: true }, + }) + + const workspace = await prisma.workspace.create({ + data: { name: "Workspace", createdByUserId: actor.user.id }, + }) + const [prototype, otherPrototype] = await Promise.all([ + prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }, + }), + prisma.prototype.create({ + data: { + name: "Other", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: otherSource.id, + }, + }), + ]) + const confirmedAt = new Date("2026-10-27T20:00:00.000Z") + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt, + }) + await prisma.gitHubAuthorizationPolicy.create({ + data: { id: "00000000-0000-0000-0000-000000000001", lastKnownGoodSeconds: 60 }, + }) + + await expect( + service.prototypeBaseline(prisma, actor.user.id, prototype.id, confirmedAt), + ).resolves.toMatchObject({ + role: "editor", + repositorySourceId: source.id, + githubRepositoryId: "100", + rawPermission: "write", + }) + await expect( + service.prototypeBaseline(prisma, actor.user.id, otherPrototype.id, confirmedAt), + ).resolves.toBeNull() + await expect( + service.prototypeBaseline( + prisma, + actor.user.id, + prototype.id, + new Date(confirmedAt.getTime() + 61_000), + ), + ).resolves.toBeNull() + }) + + it("removes synchronized state for a repository webhook while preserving manual rules", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const input = webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-1", + ) + + await expect(service.processWebhook(input)).resolves.toBe("accepted") + await expect(service.processWebhook(input)).resolves.toBe("duplicate") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "viewer" }) + expect(await prisma.gitHubWebhookDelivery.count()).toBe(1) + }) + + it("reconciles by immutable repository ID with an installation token and revokes lost synchronized access", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "session-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "master", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { userId: actor.user.id, effect: "allow", role: "commenter" }, + }, + }, + }) + const responses = [ + { token: "installation-token" }, + { + id: 100, + full_name: "new-owner/new-name", + name: "new-name", + owner: { login: "new-owner" }, + default_branch: "trunk", + }, + { user: { id: 42 }, permission: "none" }, + ] + const request = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( + () => Promise.resolve(new Response(JSON.stringify(responses.shift()), { status: 200 })), + ) + vi.stubGlobal("fetch", request) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect(request.mock.calls.map(([url]) => url)).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/new-owner/new-name/collaborators/user-42/permission", + ]) + expect(new Headers(request.mock.calls[1]?.[1]?.headers).get("authorization")).toBe( + "Bearer installation-token", + ) + const reconciled = await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + expect(reconciled).toMatchObject({ + owner: "new-owner", + name: "new-name", + defaultBranch: "trunk", + synchronizationStatus: "current", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ status: "removed", source: "synchronized" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "commenter" }) + + request.mockReset() + request.mockResolvedValue(new Response(null, { status: 503 })) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + lastConfirmedAt: reconciled?.lastConfirmedAt, + }) + }) + + it("rolls back webhook mutations when the required revocation audit fails", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "session-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_github_revocation_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = 'membership.synchronized.remove' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_github_revocation_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_github_revocation_audit(); + `) + onTestFinished(async () => { + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS reject_github_revocation_audit ON security_audits", + ) + await prisma.$executeRawUnsafe("DROP FUNCTION IF EXISTS reject_github_revocation_audit()") + }) + + await expect( + service.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-audit-failure", + ), + ), + ).rejects.toThrow("audit unavailable") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "active", admissionEnabled: true }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ status: "active" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).not.toBeNull() + expect(await prisma.gitHubWebhookDelivery.count()).toBe(0) + }) +}) diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts new file mode 100644 index 00000000..f125a2ac --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -0,0 +1,815 @@ +import { createHmac, timingSafeEqual } from "node:crypto" +import { z } from "zod" + +import { createIdentityRecords } from "#server/domains/identity/create-records" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" +import { + mapGitHubPermission, + type GitHubRepositoryPermission, +} from "#server/domains/github-authorization/map-permission" +import type { + GitHubAdmissionBaseline, + GitHubAuthorizationService, + GitHubPrototypeBaseline, +} from "#server/domains/github-authorization/create-service/types" +import { + collaboratorPermissionSchema, + repositorySchema, +} from "#server/integrations/github/check-repository-admission/constants" +import { createInstallationToken } from "#server/integrations/github/create-installation-token" +import { GitHubApiError } from "#server/integrations/github/github-api-error" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { AuthenticationError } from "#server/utils/authentication-error" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +const policyId = "00000000-0000-0000-0000-000000000001" +const repositoryIdSchema = z.string().regex(/^[1-9]\d{0,19}$/) +const identifierSchema = z.uuid() +const linkSchema = z.object({ + githubRepositoryId: repositoryIdSchema, + admissionEnabled: z.boolean().default(false), +}) +const updateSchema = z.object({ admissionEnabled: z.boolean() }) +const webhookSchema = z.object({ + action: z.string().optional(), + installation: z.object({ id: z.number().int().positive() }), + repository: z + .object({ + id: z.number().int().positive(), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1).optional(), + }) + .optional(), + repositories_added: z.array(z.object({ id: z.number().int().positive() })).optional(), + repositories_removed: z.array(z.object({ id: z.number().int().positive() })).optional(), +}) + +async function lastKnownGoodSeconds( + database: Prisma.TransactionClient | PrismaClient, +): Promise { + return ( + ( + await database.gitHubAuthorizationPolicy.findUnique({ + where: { id: policyId }, + select: { lastKnownGoodSeconds: true }, + }) + )?.lastKnownGoodSeconds ?? 86_400 + ) +} + +function authenticationDependencies(store: IdentityStore) { + return { + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(null) }, + now: () => new Date(), + } +} + +async function currentAdministrator( + store: IdentityStore, + sessionToken: string, + database?: Prisma.TransactionClient, +) { + const actor = await getCurrentSession( + authenticationDependencies(store), + sessionToken, + database ? createIdentityRecords(database) : store.read, + ) + if ( + actor.membership.role !== "administrator" || + !actor.freshAuthorizedAt || + Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000 + ) { + throw new AuthenticationError( + 403, + actor.membership.role === "administrator" + ? "fresh_authorization_required" + : "installation_administrator_required", + actor.membership.role === "administrator" + ? "Verify your identity again." + : "Installation administrator access is required.", + ) + } + return actor +} + +async function credentials( + store: IdentityStore, + provider: ReturnType, +): Promise { + const value = provider.read(await store.read.githubIntegrationState()) + if (!value) { + throw new AuthenticationError( + 503, + "source_integration_unconfigured", + "GitHub source integration is not configured.", + ) + } + return value +} + +async function permissionFor( + token: string, + username: string, + subject: string, + fullName: string, +): Promise { + try { + const response = collaboratorPermissionSchema.parse( + await requestGitHubApi( + `/repos/${fullName}/collaborators/${encodeURIComponent(username)}/permission`, + token, + ), + ) + if (String(response.user.id) !== subject) { + throw new AuthenticationError( + 403, + "identity_mismatch", + "The repository permission identity did not match.", + ) + } + return response.permission + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return "none" + throw error + } +} + +async function lockRepository(database: Prisma.TransactionClient, id: string): Promise { + await database.$queryRaw` + SELECT "id" FROM "github_repository_sources" WHERE "id" = ${id}::uuid FOR UPDATE + ` +} + +async function saveBaseline( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + requireAdmissionEnabled = false, +) { + await lockRepository(database, baseline.repositorySourceId) + const source = await database.gitHubRepositorySource.findUnique({ + where: { id: baseline.repositorySourceId }, + select: { status: true, admissionEnabled: true }, + }) + if ( + !source || + source.status !== "active" || + (requireAdmissionEnabled && !source.admissionEnabled) + ) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + await database.gitHubSynchronizedAccess.upsert({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + create: { + repositorySourceId: baseline.repositorySourceId, + userId, + rawPermission: baseline.rawPermission, + mappedPrototypeRole: baseline.prototypeRole, + admissionEligible: baseline.admissionEligible, + lastConfirmedAt: baseline.confirmedAt, + }, + update: { + rawPermission: baseline.rawPermission, + mappedPrototypeRole: baseline.prototypeRole, + admissionEligible: baseline.admissionEligible, + status: baseline.admissionEligible ? "active" : "removed", + synchronizationStatus: baseline.admissionEligible ? "current" : "removed", + lastConfirmedAt: baseline.confirmedAt, + removedAt: baseline.admissionEligible ? null : baseline.confirmedAt, + }, + }) +} + +async function enforceSynchronizedMemberships( + database: Prisma.TransactionClient, + at: Date, +): Promise { + const cutoff = new Date(at.getTime() - (await lastKnownGoodSeconds(database)) * 1_000) + const synchronizedMemberships = await database.installationMembership.findMany({ + where: { source: "synchronized", status: "active" }, + select: { id: true, userId: true }, + }) + for (const membership of synchronizedMemberships) { + const eligible = await database.gitHubSynchronizedAccess.findFirst({ + where: { + userId: membership.userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + }) + if (!eligible) { + await database.installationMembership.update({ + where: { id: membership.id }, + data: { status: "removed", removedAt: at }, + }) + await database.session.deleteMany({ where: { userId: membership.userId } }) + await saveSecurityAudit(database, { + event: "membership.synchronized.remove", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) + } + } +} + +async function removeRepositorySource( + database: Prisma.TransactionClient, + repositorySourceId: string, + removedAt: Date, +): Promise { + await database.gitHubRepositorySource.update({ + where: { id: repositorySourceId }, + data: { + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + removedAt, + }, + }) + await database.gitHubSynchronizedAccess.updateMany({ + where: { repositorySourceId }, + data: { status: "removed", synchronizationStatus: "removed", removedAt }, + }) + await enforceSynchronizedMemberships(database, removedAt) +} + +export function createGitHubAuthorizationService({ + database, + environment, + store, +}: { + database: PrismaClient + environment: NodeJS.ProcessEnv + store: IdentityStore +}): GitHubAuthorizationService { + const webhookSecret = environment.HUB_GITHUB_WEBHOOK_SECRET ?? null + if (webhookSecret && (webhookSecret.length < 20 || webhookSecret.length > 1024)) { + throw new Error("HUB_GITHUB_WEBHOOK_SECRET must contain between 20 and 1024 characters.") + } + + async function sourceCredentials() { + return credentials(store, createGitHubIntegrationCredentialProvider(environment)) + } + + async function reconcile(repositorySourceId: string, actorSessionToken?: string) { + const source = await database.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + const integration = await sourceCredentials() + if ( + integration.appId !== source.appId || + integration.installationId !== source.installationId + ) { + throw new AuthenticationError( + 409, + "repository_source_configuration_changed", + "The GitHub source integration configuration changed. Relink the repository.", + ) + } + const attemptedAt = new Date() + await database.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { lastAttemptedAt: attemptedAt, synchronizationStatus: "dirty", dirtyAt: attemptedAt }, + }) + try { + const token = await createInstallationToken(integration) + const repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), + ) + const identities = await database.externalIdentity.findMany({ + where: { provider: "github", issuer: "https://github.com", username: { not: null } }, + select: { userId: true, subject: true, username: true }, + }) + const confirmedAt = new Date() + const results = await Promise.all( + identities.map(async (identity) => { + const rawPermission = await permissionFor( + token, + identity.username!, + identity.subject, + repository.full_name, + ) + return { + userId: identity.userId, + rawPermission, + ...mapGitHubPermission(rawPermission), + } + }), + ) + await database.$transaction(async (transaction) => { + await lockRepository(transaction, source.id) + const actor = actorSessionToken + ? await currentAdministrator(store, actorSessionToken, transaction) + : null + await transaction.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + status: "active", + removedAt: null, + synchronizationStatus: "current", + synchronizationError: null, + dirtyAt: null, + lastConfirmedAt: confirmedAt, + }, + }) + for (const result of results) { + await saveBaseline(transaction, result.userId, { + repositorySourceId: source.id, + rawPermission: result.rawPermission, + admissionEligible: result.admissionEligible, + prototypeRole: result.prototypeRole, + confirmedAt, + }) + } + await enforceSynchronizedMemberships(transaction, confirmedAt) + await saveSecurityAudit(transaction, { + event: "github.repository.reconcile", + actorUserId: actor?.userId ?? null, + sessionId: actor?.id ?? null, + authenticationProviderRevisionId: actor?.authenticationProviderRevisionId ?? null, + githubRepositorySourceId: source.id, + context: { source: "github_app_installation" }, + }) + }) + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) { + await database.$transaction(async (transaction) => { + await lockRepository(transaction, source.id) + await removeRepositorySource(transaction, source.id, new Date()) + }) + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } + await database.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { + synchronizationStatus: "failed", + synchronizationError: + error instanceof GitHubApiError ? `github_${error.status}` : "provider_unavailable", + }, + }) + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + } + + return { + async evaluateAdmission(profile) { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active", admissionEnabled: true }, + orderBy: { id: "asc" }, + }) + if (sources.length === 0) return null + const identity = await database.externalIdentity.findUnique({ + where: { + provider_issuer_subject: { + provider: "github", + issuer: "https://github.com", + subject: profile.subject, + }, + }, + include: { user: { include: { memberships: true } } }, + }) + const cutoff = new Date(Date.now() - (await lastKnownGoodSeconds(database)) * 1_000) + const cached = identity + ? await database.gitHubSynchronizedAccess.findFirst({ + where: { + userId: identity.userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + orderBy: { lastConfirmedAt: "desc" }, + }) + : null + try { + const integration = await sourceCredentials() + const token = await createInstallationToken(integration) + for (const source of sources) { + if ( + source.appId !== integration.appId || + source.installationId !== integration.installationId + ) { + continue + } + const rawPermission = await permissionFor( + token, + profile.username, + profile.subject, + `${source.owner}/${source.name}`, + ) + const mapped = mapGitHubPermission(rawPermission) + if (mapped.admissionEligible) { + return { + repositorySourceId: source.id, + rawPermission, + ...mapped, + confirmedAt: new Date(), + } + } + } + return null + } catch (error) { + if (cached) { + const mapped = mapGitHubPermission(cached.rawPermission as GitHubRepositoryPermission) + return { + repositorySourceId: cached.repositorySourceId, + rawPermission: cached.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: cached.lastConfirmedAt, + } + } + const membership = identity?.user.memberships[0] + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + }, + + saveAdmissionBaseline: (transaction, userId, baseline) => + saveBaseline(transaction, userId, baseline, true), + + async prototypeBaseline( + transaction, + userId, + prototypeId, + now = new Date(), + ): Promise { + const prototype = await transaction.prototype.findUnique({ + where: { id: prototypeId }, + select: { + authorizationMode: true, + sourceRepositoryId: true, + }, + }) + if (prototype?.authorizationMode !== "github-synchronized" || !prototype.sourceRepositoryId) { + return null + } + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const baseline = await transaction.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: prototype.sourceRepositoryId, + userId, + }, + }, + include: { repositorySource: true }, + }) + if ( + !baseline || + baseline.status !== "active" || + !baseline.mappedPrototypeRole || + baseline.lastConfirmedAt < cutoff || + baseline.repositorySource.status !== "active" + ) { + return null + } + return { + role: baseline.mappedPrototypeRole, + repositorySourceId: baseline.repositorySourceId, + githubRepositoryId: baseline.repositorySource.githubRepositoryId, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + confirmedAt: baseline.lastConfirmedAt, + synchronizationStatus: baseline.repositorySource.synchronizationStatus, + } + }, + + async listRepositories(sessionToken) { + await currentAdministrator(store, sessionToken) + return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) + }, + + async linkRepository(sessionToken, input) { + const parsed = linkSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_repository_link", + "The repository link is invalid.", + ) + } + await currentAdministrator(store, sessionToken) + const integration = await sourceCredentials() + const token = await createInstallationToken(integration) + const repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${parsed.data.githubRepositoryId}`, token), + ) + return store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + const linked = await transaction.gitHubRepositorySource.upsert({ + where: { githubRepositoryId: String(repository.id) }, + create: { + githubRepositoryId: String(repository.id), + appId: integration.appId, + installationId: integration.installationId, + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + admissionEnabled: parsed.data.admissionEnabled, + dirtyAt: new Date(), + }, + update: { + appId: integration.appId, + installationId: integration.installationId, + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + status: "active", + removedAt: null, + admissionEnabled: parsed.data.admissionEnabled, + synchronizationStatus: "dirty", + dirtyAt: new Date(), + }, + }) + await saveSecurityAudit(transaction, { + event: "github.repository.link", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: linked.id, + context: { source: "github_app_installation" }, + }) + return linked + }) + }, + + async updateRepository(sessionToken, repositorySourceId, input) { + const parsed = updateSchema.safeParse(input) + if (!parsed.success || !identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 422, + "invalid_repository_policy", + "The repository policy is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + await lockRepository(transaction, repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + const updated = await transaction.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { admissionEnabled: parsed.data.admissionEnabled }, + }) + await saveSecurityAudit(transaction, { + event: "github.repository.policy", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: source.id, + context: { + reason: parsed.data.admissionEnabled ? "admission_enabled" : "admission_disabled", + }, + }) + return updated + }) + }, + + async unlinkRepository(sessionToken, repositorySourceId) { + if (!identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + await store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + await lockRepository(transaction, repositorySourceId) + const removedAt = new Date() + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + await removeRepositorySource(transaction, source.id, removedAt) + await saveSecurityAudit(transaction, { + event: "github.repository.unlink", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: source.id, + context: { source: "github_app_installation" }, + }) + }) + }, + + async reconcileRepository(sessionToken, repositorySourceId) { + await currentAdministrator(store, sessionToken) + if (!identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + return reconcile(repositorySourceId, sessionToken) + }, + + async reconcileDue() { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active" }, + select: { id: true }, + }) + for (const source of sources) { + try { + await reconcile(source.id) + } catch { + // The source record stores the explicit failure and bounded last-known-good state. + } + } + await database.$transaction((transaction) => + enforceSynchronizedMemberships(transaction, new Date()), + ) + }, + + async processWebhook({ body, deliveryId, event, signature }) { + if (!webhookSecret) { + throw new AuthenticationError( + 503, + "github_webhook_unconfigured", + "GitHub webhook verification is not configured.", + ) + } + const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}` + const valid = + signature.length === expected.length && + timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + if (!valid) { + throw new AuthenticationError( + 401, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + if (!/^[A-Za-z0-9._-]{1,128}$/.test(deliveryId) || !/^[a-z_]{1,64}$/.test(event)) { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + let decoded: unknown + try { + decoded = JSON.parse(body.toString("utf8")) + } catch { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + const payload = webhookSchema.safeParse(decoded) + if (!payload.success) { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + const integration = await sourceCredentials() + if (String(payload.data.installation.id) !== integration.installationId) return "ignored" + const relevant = new Set([ + "installation", + "installation_repositories", + "repository", + "member", + "membership", + "team_add", + ]) + if (!relevant.has(event)) return "ignored" + try { + return await database.$transaction(async (transaction) => { + const repositoryId = payload.data.repository?.id + const source = repositoryId + ? await transaction.gitHubRepositorySource.findUnique({ + where: { githubRepositoryId: String(repositoryId) }, + }) + : null + await transaction.gitHubWebhookDelivery.create({ + data: { + deliveryId, + event, + action: payload.data.action ?? null, + installationId: integration.installationId, + repositorySourceId: source?.id ?? null, + }, + }) + const now = new Date() + if ( + event === "installation" && + ["deleted", "suspend"].includes(payload.data.action ?? "") + ) { + const sources = await transaction.gitHubRepositorySource.findMany({ + where: { installationId: integration.installationId, status: "active" }, + select: { id: true }, + }) + for (const source of sources) { + await removeRepositorySource(transaction, source.id, now) + } + } else { + const dirtyIds = new Set() + const removedIds = new Set() + for (const repository of payload.data.repositories_added ?? []) + dirtyIds.add(String(repository.id)) + for (const repository of payload.data.repositories_removed ?? []) + removedIds.add(String(repository.id)) + if (repositoryId) dirtyIds.add(String(repositoryId)) + if (removedIds.size > 0) { + const removedSources = await transaction.gitHubRepositorySource.findMany({ + where: { githubRepositoryId: { in: [...removedIds] }, status: "active" }, + select: { id: true }, + }) + for (const source of removedSources) { + await removeRepositorySource(transaction, source.id, now) + } + } + await transaction.gitHubRepositorySource.updateMany({ + where: + dirtyIds.size > 0 + ? { githubRepositoryId: { in: [...dirtyIds] }, status: "active" } + : { installationId: integration.installationId, status: "active" }, + data: { synchronizationStatus: "dirty", dirtyAt: now }, + }) + } + await saveSecurityAudit(transaction, { + event: "github.webhook.process", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + githubRepositorySourceId: source?.id ?? null, + context: { source: "github_webhook" }, + }) + return "accepted" as const + }) + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as { code?: string }).code === "P2002" + ) { + return "duplicate" + } + throw error + } + }, + } +} diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts new file mode 100644 index 00000000..d9091158 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -0,0 +1,49 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { GitHubRepositoryPermission, MappedGitHubPermission } from "../map-permission/index.ts" + +export interface GitHubAdmissionBaseline extends MappedGitHubPermission { + repositorySourceId: string + rawPermission: GitHubRepositoryPermission + confirmedAt: Date +} + +export interface GitHubPrototypeBaseline { + role: NonNullable + repositorySourceId: string + githubRepositoryId: string + rawPermission: GitHubRepositoryPermission + confirmedAt: Date + synchronizationStatus: string +} + +export interface GitHubAuthorizationService { + evaluateAdmission(profile: GitHubUserProfile): Promise + saveAdmissionBaseline( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + ): Promise + prototypeBaseline( + database: Prisma.TransactionClient, + userId: string, + prototypeId: string, + now?: Date, + ): Promise + listRepositories(sessionToken: string): Promise + linkRepository(sessionToken: string, input: unknown): Promise + updateRepository( + sessionToken: string, + repositorySourceId: string, + input: unknown, + ): Promise + unlinkRepository(sessionToken: string, repositorySourceId: string): Promise + reconcileRepository(sessionToken: string, repositorySourceId: string): Promise + reconcileDue(): Promise + processWebhook(input: { + deliveryId: string + event: string + signature: string + body: Buffer + }): Promise<"accepted" | "duplicate" | "ignored"> +} diff --git a/hub/server/domains/github-authorization/map-permission/index.test.ts b/hub/server/domains/github-authorization/map-permission/index.test.ts new file mode 100644 index 00000000..4346a22d --- /dev/null +++ b/hub/server/domains/github-authorization/map-permission/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" + +import { mapGitHubPermission } from "#server/domains/github-authorization/map-permission" + +describe("mapGitHubPermission()", () => { + it.each([ + ["admin", "editor"], + ["maintain", "editor"], + ["write", "editor"], + ["triage", "commenter"], + ["read", "viewer"], + ] as const)("maps %s access to an admission-eligible %s role", (permission, prototypeRole) => { + expect(mapGitHubPermission(permission)).toEqual({ + admissionEligible: true, + prototypeRole, + }) + }) + + it("denies admission and prototype access when GitHub reports no permission", () => { + expect(mapGitHubPermission("none")).toEqual({ + admissionEligible: false, + prototypeRole: null, + }) + }) +}) diff --git a/hub/server/domains/github-authorization/map-permission/index.ts b/hub/server/domains/github-authorization/map-permission/index.ts new file mode 100644 index 00000000..cf93bb0f --- /dev/null +++ b/hub/server/domains/github-authorization/map-permission/index.ts @@ -0,0 +1,25 @@ +import type { PrototypeRole } from "#server/domains/prototype-authorization/evaluate-access/types" + +export type GitHubRepositoryPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none" + +export interface MappedGitHubPermission { + admissionEligible: boolean + prototypeRole: PrototypeRole | null +} + +export function mapGitHubPermission( + permission: GitHubRepositoryPermission, +): MappedGitHubPermission { + switch (permission) { + case "admin": + case "maintain": + case "write": + return { admissionEligible: true, prototypeRole: "editor" } + case "triage": + return { admissionEligible: true, prototypeRole: "commenter" } + case "read": + return { admissionEligible: true, prototypeRole: "viewer" } + case "none": + return { admissionEligible: false, prototypeRole: null } + } +} diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 98ae7e05..ff60c488 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -1546,9 +1546,16 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) it("returns an empty terminal prototype page for a baseline-authorized user", async () => { - const baselineRole = vi.fn().mockResolvedValue("viewer") + const baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) const baseline: PrototypeBaselinePolicy = { - role: baselineRole, + baseline: baselineAccess, } const { prisma, service, store } = await database(baseline) const actor = await createActor(store, { @@ -1575,7 +1582,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { limit: 20, }), ).resolves.toEqual({ items: [], nextCursor: null }) - expect(baselineRole.mock.calls).toContainEqual([actor.user.id, prototype.id]) + expect(baselineAccess.mock.calls).toContainEqual([actor.user.id, prototype.id]) }) it("uses baseline authorization for single-prototype reads and role-gated mutations", async () => { @@ -1744,11 +1751,11 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) it("lets a workspace deny override prototype and baseline access when listing", async () => { - const baselineRole = vi - .fn() + const baselineAccess = vi + .fn() .mockRejectedValue(new Error("baseline unavailable")) const baseline: PrototypeBaselinePolicy = { - role: baselineRole, + baseline: baselineAccess, } const { prisma, service, store } = await database(baseline) const actor = await createActor(store, { @@ -1790,14 +1797,14 @@ describe("createPrototypeAuthorizationService() database behavior", () => { code: "prototype_access_denied", }) expect(prototypeScan).not.toHaveBeenCalled() - expect(baselineRole.mock.calls).toHaveLength(0) + expect(baselineAccess.mock.calls).toHaveLength(0) }) it("uses an explicit prototype role without consulting a failing baseline", async () => { - const baselineRole = vi - .fn() + const baselineAccess = vi + .fn() .mockRejectedValue(new Error("baseline unavailable")) - const { prisma, service, store } = await database({ role: baselineRole }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) const actor = await createActor(store, { subject: "explicit-role-baseline-failure", token: "explicit-role-baseline-failure-token", @@ -1843,7 +1850,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { "workspaceId", ].sort(), ) - expect(baselineRole.mock.calls).toHaveLength(0) + expect(baselineAccess.mock.calls).toHaveLength(0) }) it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 3563da47..64281407 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -33,6 +33,10 @@ const grantSchema = z.discriminatedUnion("effect", [ ]) const roles: PrototypeRole[] = ["viewer", "commenter", "editor", "administrator"] const identifierSchema = z.uuid() +const authorizationSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("hub-managed") }), + z.object({ mode: z.literal("github-synchronized"), repositorySourceId: z.uuid() }), +]) function authenticationDependencies(store: IdentityStore) { return { @@ -40,7 +44,7 @@ function authenticationDependencies(store: IdentityStore) { github: null, configuration: null, providerRevisionId: null, - admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(false) }, + admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(null) }, now: () => new Date(), } } @@ -166,6 +170,18 @@ async function lockPrototype( await database.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` } +async function lockGitHubRepositorySource( + database: Prisma.TransactionClient, + repositorySourceId: string, +): Promise { + await database.$queryRaw` + SELECT "id" + FROM "github_repository_sources" + WHERE "id" = ${repositorySourceId}::uuid + FOR UPDATE + ` +} + async function lockWorkspacePrototypes( database: Prisma.TransactionClient, workspaceId: string, @@ -337,10 +353,61 @@ async function prototypeAccessDecision( }) if (hubDecision.source !== "none" || !baseline) return hubDecision return evaluatePrototypeAccess({ - baselineRole: await baseline.role(userId, prototype.id), + baseline: await baseline.baseline(userId, prototype.id), }) } +async function readAccessibleWorkspacePage({ + baseline, + database, + userId, + cursor, + limit, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + cursor?: string + limit: number +}) { + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.workspace.findMany({ + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + const workspaceGrant = candidate.grants[0] ?? null + if ( + evaluatePrototypeAccess({ workspaceGrant }).allowed || + (await hasAnyPrototypeAccess({ + baseline, + database, + userId, + workspaceId: candidate.id, + workspaceGrant, + })) + ) { + items.push({ + id: candidate.id, + createdByUserId: candidate.createdByUserId, + name: candidate.name, + createdAt: candidate.createdAt, + updatedAt: candidate.updatedAt, + }) + } + if (items.length > limit) break + } + if (candidates.length < 100) break + } + return pageResult(items, limit) +} + async function readAccessiblePrototypePage({ baseline, database, @@ -467,25 +534,13 @@ export function createPrototypeAuthorizationService({ async listWorkspaces(sessionToken, query) { const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) - const items = await database.workspace.findMany({ - where: { - ...(cursor ? { id: { gt: cursor } } : {}), - grants: { - some: { userId: actor.userId, effect: "allow" }, - none: { userId: actor.userId, effect: "deny" }, - }, - }, - orderBy: { id: "asc" }, - take: limit + 1, - select: { - id: true, - createdByUserId: true, - name: true, - createdAt: true, - updatedAt: true, - }, + return readAccessibleWorkspacePage({ + baseline, + database, + userId: actor.userId, + cursor, + limit, }) - return pageResult(items, limit) }, async createWorkspace(sessionToken, input) { @@ -721,6 +776,7 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, + baseline, ) requireDecision(decision, "editor") if (prototype.archivedAt) { @@ -749,6 +805,64 @@ export function createPrototypeAuthorizationService({ return decision }, + async setPrototypeAuthorization(sessionToken, prototypeId, input) { + const parsed = authorizationSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_authorization_policy", + "The prototype authorization policy is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + baseline, + ) + requireDecision(decision, "administrator") + if (parsed.data.mode === "github-synchronized") { + await lockGitHubRepositorySource(transaction, parsed.data.repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: parsed.data.repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 422, + "repository_source_unavailable", + "The linked GitHub repository is unavailable.", + ) + } + } + const updated = await transaction.prototype.update({ + where: { id: prototypeId }, + data: + parsed.data.mode === "hub-managed" + ? { authorizationMode: "hub-managed", sourceRepositoryId: null } + : { + authorizationMode: "github-synchronized", + sourceRepositoryId: parsed.data.repositorySourceId, + }, + }) + await audit(transaction, actor, { + event: "prototype.authorization.policy", + workspaceId: prototype.workspaceId, + prototypeId, + githubRepositorySourceId: + parsed.data.mode === "github-synchronized" ? parsed.data.repositorySourceId : null, + context: { + reason: parsed.data.mode === "hub-managed" ? "hub_managed" : "github_synchronized", + }, + }) + return updated + }) + }, + async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { const change = parseGrant(input) const targetId = normalizeIdentifier( @@ -766,6 +880,7 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, + baseline, ) requireDecision(decision, "administrator") await requireActiveTarget(transaction, targetId) @@ -808,6 +923,7 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, + baseline, ) requireDecision(decision, "administrator") if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, null))) { @@ -866,6 +982,7 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, + baseline, ) requireDecision(decision, "editor") if (prototype.archivedAt) { diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index fc197973..8419000a 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -1,4 +1,4 @@ -import type { AccessDecision, PrototypeRole } from "../evaluate-access/types.ts" +import type { AccessDecision, AccessInput, PrototypeRole } from "../evaluate-access/types.ts" export interface Page { items: T[] @@ -24,6 +24,11 @@ export interface PrototypeAuthorizationService { createPrototype(sessionToken: string, workspaceId: string, input: unknown): Promise getPrototype(sessionToken: string, prototypeId: string): Promise archivePrototype(sessionToken: string, prototypeId: string): Promise + setPrototypeAuthorization( + sessionToken: string, + prototypeId: string, + input: unknown, + ): Promise explainPrototypeAccess(sessionToken: string, prototypeId: string): Promise setPrototypeAccess( sessionToken: string, @@ -47,5 +52,5 @@ export interface GrantInput { } export interface PrototypeBaselinePolicy { - role(userId: string, prototypeId: string): Promise + baseline(userId: string, prototypeId: string): Promise } diff --git a/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts index e3fab4a9..96a73a58 100644 --- a/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts @@ -2,13 +2,22 @@ import { describe, expect, it } from "vitest" import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" +const baseline = { + role: "editor" as const, + repositorySourceId: "repository-source", + githubRepositoryId: "123", + rawPermission: "write", + confirmedAt: new Date("2026-10-27T20:00:00.000Z"), + synchronizationStatus: "current", +} + describe("evaluatePrototypeAccess()", () => { it("uses a prototype role even when it restricts a stronger workspace role", () => { expect( evaluatePrototypeAccess({ workspaceGrant: { effect: "allow", role: "administrator" }, prototypeGrant: { effect: "allow", role: "viewer" }, - baselineRole: "editor", + baseline, }), ).toEqual({ allowed: true, @@ -36,7 +45,7 @@ describe("evaluatePrototypeAccess()", () => { evaluatePrototypeAccess({ workspaceGrant: input.workspaceGrant, prototypeGrant: input.prototypeGrant, - baselineRole: "administrator", + baseline: { ...baseline, role: "administrator" }, }), ).toEqual({ allowed: false, @@ -46,11 +55,24 @@ describe("evaluatePrototypeAccess()", () => { }) }) - it("orders workspace access before a future baseline", () => { + it("orders a prototype role before a workspace role before the GitHub baseline", () => { + expect( + evaluatePrototypeAccess({ + prototypeGrant: { effect: "allow", role: "viewer" }, + workspaceGrant: { effect: "allow", role: "commenter" }, + baseline, + }), + ).toEqual({ + allowed: true, + role: "viewer", + source: "hub-prototype", + reason: "prototype_role", + }) + expect( evaluatePrototypeAccess({ workspaceGrant: { effect: "allow", role: "commenter" }, - baselineRole: "administrator", + baseline: { ...baseline, role: "administrator" }, }), ).toEqual({ allowed: true, @@ -59,11 +81,18 @@ describe("evaluatePrototypeAccess()", () => { reason: "workspace_role", }) - expect(evaluatePrototypeAccess({ baselineRole: "editor" })).toEqual({ + expect(evaluatePrototypeAccess({ baseline })).toEqual({ allowed: true, role: "editor", - source: "baseline", - reason: "baseline_role", + source: "github-synchronized", + reason: "github_baseline", + baseline: { + repositorySourceId: "repository-source", + githubRepositoryId: "123", + rawPermission: "write", + confirmedAt: new Date("2026-10-27T20:00:00.000Z"), + synchronizationStatus: "current", + }, }) }) diff --git a/hub/server/domains/prototype-authorization/evaluate-access/index.ts b/hub/server/domains/prototype-authorization/evaluate-access/index.ts index 2d258e26..d4d9eac7 100644 --- a/hub/server/domains/prototype-authorization/evaluate-access/index.ts +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.ts @@ -6,7 +6,7 @@ import type { export function evaluatePrototypeAccess({ workspaceGrant, prototypeGrant, - baselineRole = null, + baseline = null, }: AccessInput): AccessDecision { if (prototypeGrant?.effect === "deny" || workspaceGrant?.effect === "deny") { return { @@ -32,12 +32,19 @@ export function evaluatePrototypeAccess({ reason: "workspace_role", } } - if (baselineRole) { + if (baseline) { return { allowed: true, - role: baselineRole, - source: "baseline", - reason: "baseline_role", + role: baseline.role, + source: "github-synchronized", + reason: "github_baseline", + baseline: { + repositorySourceId: baseline.repositorySourceId, + githubRepositoryId: baseline.githubRepositoryId, + rawPermission: baseline.rawPermission, + confirmedAt: baseline.confirmedAt, + synchronizationStatus: baseline.synchronizationStatus, + }, } } return { diff --git a/hub/server/domains/prototype-authorization/evaluate-access/types.ts b/hub/server/domains/prototype-authorization/evaluate-access/types.ts index 0ce29321..1e760fa4 100644 --- a/hub/server/domains/prototype-authorization/evaluate-access/types.ts +++ b/hub/server/domains/prototype-authorization/evaluate-access/types.ts @@ -8,12 +8,26 @@ export interface AccessGrant { export interface AccessDecision { allowed: boolean role: PrototypeRole | null - source: "hub-prototype" | "hub-workspace" | "baseline" | "none" - reason: "explicit_deny" | "prototype_role" | "workspace_role" | "baseline_role" | "no_access" + source: "hub-prototype" | "hub-workspace" | "github-synchronized" | "none" + reason: "explicit_deny" | "prototype_role" | "workspace_role" | "github_baseline" | "no_access" + baseline?: { + repositorySourceId: string + githubRepositoryId: string + rawPermission: string + confirmedAt: Date + synchronizationStatus: string + } } export interface AccessInput { workspaceGrant?: AccessGrant | null prototypeGrant?: AccessGrant | null - baselineRole?: PrototypeRole | null + baseline?: { + role: PrototypeRole + repositorySourceId: string + githubRepositoryId: string + rawPermission: string + confirmedAt: Date + synchronizationStatus: string + } | null } diff --git a/hub/server/index.ts b/hub/server/index.ts index af5b0cbc..ecf78b6b 100644 --- a/hub/server/index.ts +++ b/hub/server/index.ts @@ -10,6 +10,14 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) { } const authentication = await createAuthenticationRuntime(process.env) +const reconciliationInterval = setInterval(() => { + void authentication.githubAuthorization.reconcileDue().catch(() => { + log("error", "GitHub authorization reconciliation failed", { + reason: "reconciliation_failed", + }) + }) +}, 15 * 60_000) +reconciliationInterval.unref() createApp({ authentication }).listen(port, "127.0.0.1", () => { log("info", "Hub API listening", { host: "127.0.0.1", port }) diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts index 16b48ba8..8fcb6b80 100644 --- a/hub/server/integrations/github/check-repository-admission/constants.ts +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -3,7 +3,11 @@ import { z } from "zod" export const installedRepositoriesSchema = z.object({ repositories: z.array( z.object({ + id: z.number().int().positive(), full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1), private: z.boolean(), }), ), @@ -17,3 +21,11 @@ export const collaboratorPermissionSchema = z.object({ export const repositoryAdmissionBatchSize = 10 export const repositoryAdmissionLimit = 100 export const repositoryAdmissionTimeoutMs = 10_000 + +export const repositorySchema = z.object({ + id: z.number().int().positive(), + full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1), +}) diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts index 6b63ca08..12c3a6dd 100644 --- a/hub/server/repositories/security-audits/save-audit/index.ts +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -28,6 +28,7 @@ export async function saveSecurityAudit(database: Prisma.TransactionClient, data prototypeId: data.prototypeId ?? null, prototypeVersionId: data.prototypeVersionId ?? null, targetUserId: data.targetUserId ?? null, + githubRepositorySourceId: data.githubRepositorySourceId ?? null, context, }, }) diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index fe15ab02..ef4c2ef7 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -10,6 +10,7 @@ export interface AuditInput { prototypeId?: string | null prototypeVersionId?: string | null targetUserId?: string | null + githubRepositorySourceId?: string | null context?: Record } @@ -26,6 +27,7 @@ export interface SecurityAuditRecord { prototypeId: string | null prototypeVersionId: string | null targetUserId: string | null + githubRepositorySourceId: string | null context: unknown createdAt: Date } diff --git a/hub/server/routes/github-authorization/index.test.ts b/hub/server/routes/github-authorization/index.test.ts new file mode 100644 index 00000000..0a2c3a0a --- /dev/null +++ b/hub/server/routes/github-authorization/index.test.ts @@ -0,0 +1,136 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" + +import { createApp } from "../../create-app/index.ts" + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const protectedHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "content-type": "application/json", + "x-csrf-token": csrfToken, +} + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { githubAuthorization: Mocked } { + const githubAuthorization = Object.assign( + { + evaluateAdmission: vi.fn(), + saveAdmissionBaseline: vi.fn(), + prototypeBaseline: vi.fn(), + listRepositories: vi.fn().mockResolvedValue([]), + linkRepository: vi.fn(), + updateRepository: vi.fn(), + unlinkRepository: vi.fn(), + reconcileRepository: vi.fn(), + reconcileDue: vi.fn(), + processWebhook: vi.fn(), + }, + overrides, + ) as Mocked + return { + service: {} as Mocked, + administration: {} as Mocked, + prototypeAuthorization: {} as Mocked, + githubAuthorization, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes) { + const server = createServer(createApp({ authentication })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe("GitHub authorization routes", () => { + it("passes the authenticated session to repository listing without accepting a body token", async () => { + const repository = { id: "repository-source" } + const listRepositories = vi.fn().mockResolvedValue([repository]) + const authentication = runtime({ listRepositories }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/github/repositories`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ repositories: [repository] }) + expect(listRepositories).toHaveBeenCalledWith(sessionToken) + }) + + it("rejects repository mutations before the service when CSRF does not match", async () => { + const linkRepository = vi.fn() + const authentication = runtime({ linkRepository }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/github/repositories`, { + method: "POST", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + body: JSON.stringify({ githubRepositoryId: "123" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(linkRepository).not.toHaveBeenCalled() + }) + + it.each([ + ["accepted", 202], + ["duplicate", 200], + ["ignored", 200], + ] as const)( + "forwards the verified webhook envelope and returns %s as HTTP %i", + async (result, status) => { + const body = Buffer.from('{"installation":{"id":7}}') + const processWebhook = vi.fn().mockResolvedValue(result) + const authentication = runtime({ processWebhook }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/github/webhooks`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-delivery": "delivery-1", + "x-github-event": "installation", + "x-hub-signature-256": "sha256=signature", + }, + body, + }) + + expect(response.status).toBe(status) + expect(await response.json()).toEqual({ result }) + expect(processWebhook).toHaveBeenCalledWith({ + deliveryId: "delivery-1", + event: "installation", + signature: "sha256=signature", + body, + }) + }, + ) +}) diff --git a/hub/server/routes/github-authorization/index.ts b/hub/server/routes/github-authorization/index.ts new file mode 100644 index 00000000..1af030cd --- /dev/null +++ b/hub/server/routes/github-authorization/index.ts @@ -0,0 +1,103 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +function sessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + return readCookie(request.headers.cookie, cookies.session) +} + +function mutationSessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + return readCookie(request.headers.cookie, cookies.session) +} + +function parameter(request: Request, name: string): string { + const value = request.params[name] + return typeof value === "string" ? value : "" +} + +export async function listRepositories( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + response.json({ + repositories: await authentication.githubAuthorization!.listRepositories( + sessionToken(authentication, request), + ), + }) +} + +export async function linkRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.linkRepository( + mutationSessionToken(authentication, request), + request.body, + ) + response.status(201).json({ repository }) +} + +export async function updateRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.updateRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + request.body, + ) + response.json({ repository }) +} + +export async function unlinkRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + await authentication.githubAuthorization!.unlinkRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + ) + response.status(204).end() +} + +export async function reconcileRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.reconcileRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + ) + response.json({ repository }) +} + +export async function processWebhook( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + const body = Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0) + const result = await authentication.githubAuthorization!.processWebhook({ + deliveryId: request.get("x-github-delivery") ?? "", + event: request.get("x-github-event") ?? "", + signature: request.get("x-hub-signature-256") ?? "", + body, + }) + response.status(result === "accepted" ? 202 : 200).json({ result }) +} diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index f9ad0ddd..1720c2cf 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -31,14 +31,26 @@ import { removePrototypeAccess, removeWorkspaceAccess, setPrototypeAccess, + setPrototypeAuthorization, setWorkspaceAccess, } from "#server/routes/product" +import { + linkRepository, + listRepositories, + processWebhook, + reconcileRepository, + unlinkRepository, + updateRepository, +} from "#server/routes/github-authorization" import type { AuthenticationRoutes } from "#server/routes/types" /** Keep endpoint registration separate from the app's shared middleware. */ export function routes(app: Express, authentication?: AuthenticationRoutes): void { app.get("/api/health", handleApiHealthGet) if (!authentication) return + if (authentication.githubAuthorization) { + app.post("/api/github/webhooks", processWebhook.bind(null, authentication)) + } app.use( ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], @@ -89,6 +101,22 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi "/api/administration/memberships/:id", handleApiAdministrationMembershipsPatch.bind(null, authentication), ) + if (authentication.githubAuthorization) { + app.get("/api/administration/github/repositories", listRepositories.bind(null, authentication)) + app.post("/api/administration/github/repositories", linkRepository.bind(null, authentication)) + app.patch( + "/api/administration/github/repositories/:repositorySourceId", + updateRepository.bind(null, authentication), + ) + app.delete( + "/api/administration/github/repositories/:repositorySourceId", + unlinkRepository.bind(null, authentication), + ) + app.post( + "/api/administration/github/repositories/:repositorySourceId/reconcile", + reconcileRepository.bind(null, authentication), + ) + } app.use("/api/administration", respondAdministrationError) const productMutationLimit = createRateLimit({ limit: 120, @@ -120,6 +148,11 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi productMutationLimit, archivePrototype.bind(null, authentication), ) + app.put( + "/api/prototypes/:prototypeId/authorization", + productMutationLimit, + setPrototypeAuthorization.bind(null, authentication), + ) app.get( "/api/prototypes/:prototypeId/access/explanation", explainPrototypeAccess.bind(null, authentication), diff --git a/hub/server/routes/product/index.ts b/hub/server/routes/product/index.ts index d3cac7a5..00d0ea2a 100644 --- a/hub/server/routes/product/index.ts +++ b/hub/server/routes/product/index.ts @@ -139,6 +139,19 @@ export async function archivePrototype( response.json({ prototype }) } +export async function setPrototypeAuthorization( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.setPrototypeAuthorization( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + request.body, + ) + response.json({ prototype }) +} + export async function explainPrototypeAccess( authentication: AuthenticationRoutes, request: Request, diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index b64ee8d9..530c61ce 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -4,10 +4,12 @@ import type { } from "#server/domains/authentication/create-service/types" import type { AdministrationService } from "#server/domains/administration/create-service/types" import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" export interface AuthenticationRoutes { service: AuthenticationService administration: AdministrationService prototypeAuthorization: PrototypeAuthorizationService + githubAuthorization?: GitHubAuthorizationService settings: AuthenticationSettings } From a8138b9f7548e784c3b6445a04f0d0e5dda96ce7 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 16:08:52 -0700 Subject: [PATCH 02/12] fix(hub): reject stale GitHub sync results Serialize reconciliation claims with source generations and scope outage fallback to the repository whose live permission check failed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/database.md | 2 +- hub/docs/github-integration.md | 4 +- hub/docs/testing-and-operations.md | 2 +- hub/schema/github-repository-source.prisma | 1 + .../migration.sql | 4 + .../create-service/index.db.test.ts | 363 ++++++++++++++++++ .../create-service/index.ts | 178 +++++++-- 7 files changed, 509 insertions(+), 45 deletions(-) diff --git a/hub/docs/database.md b/hub/docs/database.md index d835bf9c..dff95f05 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -52,7 +52,7 @@ Workspace and prototype access use separate grant tables so each user has at mos GitHub synchronization uses separate records: -- `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, and synchronization timestamps do not define identity. +- `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, synchronization generation, and timestamps do not define identity. The generation is a compare-and-set boundary for rejecting stale in-flight reconciliation results. - `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. - `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. - `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index f3455675..23def02f 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -31,11 +31,13 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. + `POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository or installation removal marks synchronized records removed. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. ## Staleness and outages -A successful confirmation records `lastConfirmedAt`. Failed provider calls record a bounded failure status and leave that time unchanged. Hub permits a last-known-good baseline for one day, then fails closed. Reconciliation also removes synchronized memberships and sessions when no fresh admission-eligible source remains. +A successful confirmation records `lastConfirmedAt`. Failed provider calls record a bounded failure status and leave that time unchanged. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial for one source suppresses that source's old cache, while an outage may use only the still-fresh cache for the same source. Reconciliation also removes synchronized memberships and sessions when no fresh admission-eligible source remains. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index ac03780a..a55622a6 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -59,7 +59,7 @@ A dedicated migration case applies the foundation migration, seeds retained life Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, repository rename and removal, installation removal, permission loss, provider outage, dirty webhooks, replayed deliveries, missed-event repair, manual override preservation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, repository rename and removal, installation removal, permission loss, provider outage, per-repository cache fallback, dirty webhooks, replayed deliveries, missed-event repair, stale overlapping reconciliation, unlink during reconciliation, manual override preservation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/schema/github-repository-source.prisma b/hub/schema/github-repository-source.prisma index cda1b3ef..6d64f4d0 100644 --- a/hub/schema/github-repository-source.prisma +++ b/hub/schema/github-repository-source.prisma @@ -17,6 +17,7 @@ model GitHubRepositorySource { defaultBranch String @map("default_branch") status String @default("active") admissionEnabled Boolean @default(false) @map("admission_enabled") + synchronizationGeneration Int @default(0) @map("synchronization_generation") synchronizationStatus String @default("dirty") @map("synchronization_status") synchronizationError String? @map("synchronization_error") removedAt DateTime? @map("removed_at") @db.Timestamptz(3) diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql index 1d328d76..f7118122 100644 --- a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -25,6 +25,7 @@ CREATE TABLE "github_repository_sources" ( "default_branch" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "admission_enabled" BOOLEAN NOT NULL DEFAULT false, + "synchronization_generation" INTEGER NOT NULL DEFAULT 0, "synchronization_status" TEXT NOT NULL DEFAULT 'dirty', "synchronization_error" TEXT, "removed_at" TIMESTAMPTZ(3), @@ -36,6 +37,9 @@ CREATE TABLE "github_repository_sources" ( CONSTRAINT "github_repository_sources_pkey" PRIMARY KEY ("id"), CONSTRAINT "github_repository_sources_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_repository_sources_synchronization_generation" CHECK ( + "synchronization_generation" >= 0 + ), CONSTRAINT "github_repository_sources_synchronization_status" CHECK ( "synchronization_status" IN ('dirty', 'current', 'failed', 'removed') ) diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 29fdede5..cb2676bd 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -105,6 +105,18 @@ function webhook(body: object, event: string, deliveryId: string) { } } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function requestUrl(input: string | URL | Request): string { + return typeof input === "string" ? input : input instanceof URL ? input.href : input.url +} + describe("createGitHubAuthorizationService() database behavior", () => { it("uses only a fresh baseline for the linked repository and keeps repositories isolated", async () => { const { prisma, service, store } = await database() @@ -358,6 +370,357 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("does not resurrect a repository removed while reconciliation is waiting on GitHub", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { userId: actor.user.id, effect: "allow", role: "commenter" }, + }, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const repositoryResponse = deferred() + const repositoryRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequested.resolve() + return repositoryResponse.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { + status: 200, + }), + ) + }), + ) + + const reconciliation = service.reconcileDue() + await repositoryRequested.promise + await service.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-during-reconcile", + ), + ) + repositoryResponse.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "resurrected-owner/resurrected-name", + name: "resurrected-name", + owner: { login: "resurrected-owner" }, + default_branch: "trunk", + }), + { status: 200 }, + ), + ) + await reconciliation + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + synchronizationGeneration: 2, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "commenter" }) + }) + + it("keeps the newer result when overlapping reconciliations finish out of order", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { userId: actor.user.id, effect: "deny", role: null }, + }, + }, + }) + const firstRepositoryResponse = deferred() + const secondRepositoryResponse = deferred() + const firstRepositoryRequested = deferred() + const secondRepositoryRequested = deferred() + let repositoryRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequests += 1 + if (repositoryRequests === 1) { + firstRepositoryRequested.resolve() + return firstRepositoryResponse.promise + } + secondRepositoryRequested.resolve() + return secondRepositoryResponse.promise + } + const permission = url.includes("newer-owner") ? "read" : "write" + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission }), { status: 200 }), + ) + }), + ) + + const olderReconciliation = service.reconcileDue() + await firstRepositoryRequested.promise + const newerReconciliation = service.reconcileDue() + await secondRepositoryRequested.promise + secondRepositoryResponse.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "newer-owner/newer-name", + name: "newer-name", + owner: { login: "newer-owner" }, + default_branch: "newer-branch", + }), + { status: 200 }, + ), + ) + await newerReconciliation + firstRepositoryResponse.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "older-owner/older-name", + name: "older-name", + owner: { login: "older-owner" }, + default_branch: "older-branch", + }), + { status: 200 }, + ), + ) + await olderReconciliation + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: 2, + synchronizationStatus: "current", + owner: "newer-owner", + name: "newer-name", + defaultBranch: "newer-branch", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + mappedPrototypeRole: "viewer", + status: "active", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "deny", role: null }) + expect( + await prisma.securityAudit.count({ + where: { event: "github.repository.reconcile", githubRepositorySourceId: source.id }, + }), + ).toBe(1) + }) + + it("does not use one repository's cached grant when another repository fails", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const [sourceA, sourceB] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + id: "00000000-0000-0000-0000-000000000001", + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository-a", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + id: "00000000-0000-0000-0000-000000000002", + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository-b", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: sourceA.id, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.includes("repository-a")) { + return Promise.resolve(new Response(null, { status: 404 })) + } + if (url.includes("repository-b")) { + return Promise.resolve(new Response(null, { status: 503 })) + } + throw new Error(`Unexpected request: ${url}`) + }), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).resolves.toBeNull() + + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: sourceA.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ rawPermission: "write", status: "active" }) + expect(sourceB.id).not.toBe(sourceA.id) + }) + + it("uses a fresh cached grant only when that same repository's live check fails", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const confirmedAt = new Date() + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 503 }), + ), + ), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).resolves.toEqual({ + repositorySourceId: source.id, + rawPermission: "read", + admissionEligible: true, + prototypeRole: "viewer", + confirmedAt, + }) + }) + it("rolls back webhook mutations when the required revocation audit fails", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index f125a2ac..73b997a1 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -246,6 +246,7 @@ async function removeRepositorySource( data: { status: "removed", admissionEnabled: false, + synchronizationGeneration: { increment: 1 }, synchronizationStatus: "removed", removedAt, }, @@ -276,16 +277,30 @@ export function createGitHubAuthorizationService({ } async function reconcile(repositorySourceId: string, actorSessionToken?: string) { - const source = await database.gitHubRepositorySource.findUnique({ - where: { id: repositorySourceId }, + const source = await database.$transaction(async (transaction) => { + await lockRepository(transaction, repositorySourceId) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!current || current.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + const attemptedAt = new Date() + return transaction.gitHubRepositorySource.update({ + where: { id: current.id }, + data: { + synchronizationGeneration: { increment: 1 }, + lastAttemptedAt: attemptedAt, + synchronizationStatus: "dirty", + synchronizationError: null, + dirtyAt: attemptedAt, + }, + }) }) - if (!source || source.status !== "active") { - throw new AuthenticationError( - 404, - "repository_source_not_found", - "The repository link was not found.", - ) - } const integration = await sourceCredentials() if ( integration.appId !== source.appId || @@ -297,11 +312,6 @@ export function createGitHubAuthorizationService({ "The GitHub source integration configuration changed. Relink the repository.", ) } - const attemptedAt = new Date() - await database.gitHubRepositorySource.update({ - where: { id: source.id }, - data: { lastAttemptedAt: attemptedAt, synchronizationStatus: "dirty", dirtyAt: attemptedAt }, - }) try { const token = await createInstallationToken(integration) const repository = repositorySchema.parse( @@ -327,8 +337,18 @@ export function createGitHubAuthorizationService({ } }), ) - await database.$transaction(async (transaction) => { + const applied = await database.$transaction(async (transaction) => { await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return false + } const actor = actorSessionToken ? await currentAdministrator(store, actorSessionToken, transaction) : null @@ -364,24 +384,60 @@ export function createGitHubAuthorizationService({ githubRepositorySourceId: source.id, context: { source: "github_app_installation" }, }) + return true }) + if (!applied) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) } catch (error) { + if ( + error instanceof AuthenticationError && + [ + "fresh_authorization_required", + "installation_administrator_required", + "membership_inactive", + "sign_in_required", + ].includes(error.code) + ) { + throw error + } if (error instanceof GitHubApiError && error.status === 404) { - await database.$transaction(async (transaction) => { + const removed = await database.$transaction(async (transaction) => { await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return false + } await removeRepositorySource(transaction, source.id, new Date()) + return true }) + if (!removed) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) } - await database.gitHubRepositorySource.update({ - where: { id: source.id }, + const failed = await database.gitHubRepositorySource.updateMany({ + where: { + id: source.id, + status: "active", + synchronizationGeneration: source.synchronizationGeneration, + }, data: { synchronizationStatus: "failed", synchronizationError: error instanceof GitHubApiError ? `github_${error.status}` : "provider_unavailable", }, }) + if (failed.count === 0) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } if (error instanceof AuthenticationError) throw error throw new AuthenticationError( 503, @@ -410,27 +466,57 @@ export function createGitHubAuthorizationService({ }) const cutoff = new Date(Date.now() - (await lastKnownGoodSeconds(database)) * 1_000) const cached = identity - ? await database.gitHubSynchronizedAccess.findFirst({ + ? await database.gitHubSynchronizedAccess.findMany({ where: { userId: identity.userId, status: "active", admissionEligible: true, lastConfirmedAt: { gte: cutoff }, - repositorySource: { status: "active", admissionEnabled: true }, + repositorySourceId: { in: sources.map(({ id }) => id) }, }, - orderBy: { lastConfirmedAt: "desc" }, }) - : null + : [] + const cachedBySource = new Map( + cached.map((baseline) => [baseline.repositorySourceId, baseline]), + ) + const membership = identity?.user.memberships[0] + let integration: GitHubCredentials + let token: string try { - const integration = await sourceCredentials() - const token = await createInstallationToken(integration) + integration = await sourceCredentials() + token = await createInstallationToken(integration) + } catch (error) { + if (!(error instanceof GitHubApiError)) throw error for (const source of sources) { - if ( - source.appId !== integration.appId || - source.installationId !== integration.installationId - ) { - continue + const baseline = cachedBySource.get(source.id) + if (baseline) { + const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) + return { + repositorySourceId: source.id, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } } + } + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + let providerUnavailable = false + for (const source of sources) { + if ( + source.appId !== integration.appId || + source.installationId !== integration.installationId + ) { + continue + } + try { const rawPermission = await permissionFor( token, profile.username, @@ -446,29 +532,32 @@ export function createGitHubAuthorizationService({ confirmedAt: new Date(), } } - } - return null - } catch (error) { - if (cached) { - const mapped = mapGitHubPermission(cached.rawPermission as GitHubRepositoryPermission) - return { - repositorySourceId: cached.repositorySourceId, - rawPermission: cached.rawPermission as GitHubRepositoryPermission, - ...mapped, - confirmedAt: cached.lastConfirmedAt, + } catch (error) { + if (!(error instanceof GitHubApiError)) throw error + providerUnavailable = true + const baseline = cachedBySource.get(source.id) + if (baseline) { + const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) + return { + repositorySourceId: source.id, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } } } - const membership = identity?.user.memberships[0] + } + if (providerUnavailable) { if (membership?.status === "active" && membership.source !== "synchronized") { return null } - if (error instanceof AuthenticationError) throw error throw new AuthenticationError( 503, "source_integration_unavailable", "GitHub source integration is unavailable.", ) } + return null }, saveAdmissionBaseline: (transaction, userId, baseline) => @@ -562,6 +651,7 @@ export function createGitHubAuthorizationService({ status: "active", removedAt: null, admissionEnabled: parsed.data.admissionEnabled, + synchronizationGeneration: { increment: 1 }, synchronizationStatus: "dirty", dirtyAt: new Date(), }, @@ -787,7 +877,11 @@ export function createGitHubAuthorizationService({ dirtyIds.size > 0 ? { githubRepositoryId: { in: [...dirtyIds] }, status: "active" } : { installationId: integration.installationId, status: "active" }, - data: { synchronizationStatus: "dirty", dirtyAt: now }, + data: { + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: now, + }, }) } await saveSecurityAudit(transaction, { From 329887d2c6e16bdb14e0e4505281de9029ddcdc6 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 14:02:46 -0700 Subject: [PATCH 03/12] fix(hub): preserve synchronized access boundaries Serialize synchronized membership changes, reject stale admission writes, resolve repositories by immutable ID, and keep lower-precedence baselines isolated from explicit Hub decisions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/database.md | 2 + hub/docs/github-integration.md | 10 +- hub/docs/membership-and-administration.md | 2 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 3 +- .../administration/create-service/index.ts | 2 +- .../create-service/index.db.test.ts | 754 +++++++++++++++++- .../create-service/index.ts | 240 ++++-- .../create-service/types.ts | 10 +- .../create-service/index.db.test.ts | 201 ++++- .../create-service/index.ts | 60 +- .../create-service/types.ts | 11 +- .../check-repository-admission/constants.ts | 1 + .../installation-memberships/types.ts | 3 +- 14 files changed, 1206 insertions(+), 95 deletions(-) diff --git a/hub/docs/database.md b/hub/docs/database.md index dff95f05..5aeb5897 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -59,6 +59,8 @@ GitHub synchronization uses separate records: `Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. +Installation membership `source` distinguishes linked-source synchronization from the `legacy-synchronized` compatibility policy. Any administrator lifecycle change or assignment of Hub administration converts a synchronized membership to `manual`, so reconciliation cannot undo the Hub override or orphan administration. + ## Shared client Import `prisma` from `server/integrations/prisma/index.ts` rather than constructing clients in each caller. The integration uses Prisma's [documented global cache pattern](https://www.prisma.io/docs/orm/v7/more/troubleshooting/nextjs#avoiding-multiple-prisma-client-instances) to reuse one client across development module reloads. Production uses the module instance without setting the global variable. diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index 23def02f..a2010834 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -4,7 +4,7 @@ GitHub-synchronized authorization uses repository permissions as an opt-in basel ## Source identity and credentials -A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, active or removed state, admission switch, synchronization status, and timestamps. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. +A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, active or removed state, admission switch, synchronization status, and timestamps. Admission resolves the current owner and name from that immutable ID before checking a user permission, so a renamed repository cannot be confused with a replacement at its old name. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. All permission checks use an installation token created from the configured GitHub source-integration credentials. GitHub user authorization proves identity only. Hub does not retain or reuse the user's transient token for synchronization. @@ -25,19 +25,19 @@ Each prototype separately selects `hub-managed` or `github-synchronized`. The sy | `read` | Member | `viewer` | | `none` or missing | Denied | None | -GitHub does not map to Hub installation administrator or prototype administrator. Explicit Hub deny wins first, followed by explicit prototype and workspace roles, then the synchronized baseline. +GitHub does not map to Hub installation administrator or prototype administrator. GitHub's built-in `role_name` takes precedence over its legacy base `permission` field, so `role_name: triage` maps to commenter even when the base permission is `read`; unknown custom roles fall back to the base permission. Explicit Hub deny wins first, followed by explicit prototype and workspace roles, then the synchronized baseline. ## Reconciliation and webhooks `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Admission confirmations carry that same generation and cannot overwrite a newer reconciliation result. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository or installation removal marks synchronized records removed. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. ## Staleness and outages -A successful confirmation records `lastConfirmedAt`. Failed provider calls record a bounded failure status and leave that time unchanged. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial for one source suppresses that source's old cache, while an outage may use only the still-fresh cache for the same source. Reconciliation also removes synchronized memberships and sessions when no fresh admission-eligible source remains. +A successful confirmation records `lastConfirmedAt`. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 suppresses that source's old cache, while an outage may use only the still-fresh cache for the same source. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index 2a421ddd..6a8db644 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -15,7 +15,7 @@ Each Hub user has at most one installation membership. Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. -Synchronization creates and refreshes only memberships whose source is `synchronized`. It never reactivates or rewrites a manual, invitation, bootstrap, recovery, or suspended membership. When no fresh admission-eligible baseline remains, reconciliation marks the synchronized membership removed and deletes its sessions in the same transaction. +Synchronization creates and refreshes only memberships whose source is `synchronized`. It never reactivates or rewrites a manual, invitation, bootstrap, recovery, suspended, or administratively removed membership. Administrator membership changes convert a synchronized assignment to `manual`. Assigning installation, workspace, or prototype administrator authority also makes the membership manual, because GitHub never owns Hub administration. When no fresh admission-eligible baseline remains, reconciliation marks only an ordinary synchronized member removed and deletes its sessions in the same serialized transaction. The compatibility policy uses `legacy-synchronized`, which linked-source reconciliation does not manage. Suspension and removal also check Hub-managed product administration in the same transaction. The change fails with `product_administration_orphaned` until another active user can administer every affected workspace and prototype. A role change from installation administrator to member does not remove explicit workspace or prototype roles. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index a55622a6..d413c1d4 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -59,7 +59,7 @@ A dedicated migration case applies the foundation migration, seeds retained life Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, repository rename and removal, installation removal, permission loss, provider outage, per-repository cache fallback, dirty webhooks, replayed deliveries, missed-event repair, stale overlapping reconciliation, unlink during reconciliation, manual override preservation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, immutable-ID rename resolution, repository and installation removal, permission and identity mismatch, built-in role-name mapping, provider and credential outage, per-repository cache fallback, dirty and deletion webhooks, replayed deliveries, missed-event repair, bounded request concurrency, stale overlapping reconciliation, unlink during reconciliation, manual suspension and administration preservation, compatibility membership isolation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index d1f0e9cd..9f4fc38f 100644 --- a/hub/server/domains/administration/create-service/index.db.test.ts +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -20,6 +20,7 @@ async function createAdministrator( options: { role?: "member" | "administrator" freshAuthorizedAt?: Date | null + source?: "manual" | "synchronized" } = {}, ) { const authorizedAt = new Date("2026-10-27T18:00:00.000Z") @@ -52,7 +53,7 @@ async function createAdministrator( userId: user.id, status: "active", role, - source: "manual", + source: options.source ?? "manual", suspendedAt: null, removedAt: null, }) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index 4ab0b945..28744d4f 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -318,7 +318,7 @@ export function createAdministrationService( userId: target.userId, role: nextRole, status: nextStatus, - source: target.source, + source: "manual", suspendedAt: nextStatus === "suspended" ? target.status === "suspended" diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index cb2676bd..00bc4c5a 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -1,6 +1,8 @@ import { createHmac, generateKeyPairSync } from "node:crypto" import { describe, expect, it, onTestFinished, vi } from "vitest" +import { createAdministrationService } from "#server/domains/administration/create-service" +import { createAuthenticationService } from "#server/domains/authentication/create-service" import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" import { createIdentityStore } from "#server/domains/identity/create-store" import { digestSecret } from "#server/utils/digest-secret" @@ -45,10 +47,14 @@ async function createUser( { subject, membershipSource = "manual", + role = "member", + status = "active", token, }: { subject: string - membershipSource?: "manual" | "synchronized" + membershipSource?: "legacy-synchronized" | "manual" | "synchronized" + role?: "administrator" | "member" + status?: "active" | "removed" | "suspended" token?: string }, ) { @@ -75,9 +81,11 @@ async function createUser( })) const membership = await records.saveMembership({ userId: user.id, - status: "active", - role: "member", + status, + role, source: membershipSource, + suspendedAt: status === "suspended" ? new Date() : null, + removedAt: status === "removed" ? new Date() : null, }) const session = token ? await records.createSession({ @@ -186,6 +194,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { const confirmedAt = new Date("2026-10-27T20:00:00.000Z") await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "write", prototypeRole: "editor", admissionEligible: true, @@ -245,6 +254,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -395,6 +405,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -626,6 +637,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { ]) await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: sourceA.id, + sourceGeneration: sourceA.synchronizationGeneration, rawPermission: "write", prototypeRole: "editor", admissionEligible: true, @@ -640,10 +652,33 @@ describe("createGitHubAuthorizationService() database behavior", () => { new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), ) } - if (url.includes("repository-a")) { - return Promise.resolve(new Response(null, { status: 404 })) + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository-a", + name: "repository-a", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + if (url.includes("/repos/owner/repository-a/")) { + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: 42 }, + permission: "none", + role_name: "none", + }), + { status: 200 }, + ), + ) } - if (url.includes("repository-b")) { + if (url.endsWith("/repositories/200")) { return Promise.resolve(new Response(null, { status: 503 })) } throw new Error(`Unexpected request: ${url}`) @@ -689,6 +724,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { const confirmedAt = new Date() await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -714,6 +750,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }), ).resolves.toEqual({ repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", admissionEligible: true, prototypeRole: "viewer", @@ -721,6 +758,710 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("rejects baseline saves from a stale source generation or older confirmation", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const confirmedAt = new Date("2026-10-28T18:00:00.000Z") + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt, + }) + const currentSource = await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { synchronizationGeneration: { increment: 1 } }, + }) + + await expect( + service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(confirmedAt.getTime() + 1_000), + }), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + await expect( + service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: currentSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(confirmedAt.getTime() - 1_000), + }), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + mappedPrototypeRole: "editor", + lastConfirmedAt: confirmedAt, + }) + }) + + it.each([ + { + permission: "read", + roleName: "triage", + rawPermission: "triage", + prototypeRole: "commenter", + }, + { + permission: "write", + roleName: "release-manager", + rawPermission: "write", + prototypeRole: "editor", + }, + ] as const)( + "resolves immutable repository metadata and maps role $roleName as $rawPermission", + async ({ permission, roleName, rawPermission, prototypeRole }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const request = vi.fn((input: string | URL | Request): Promise => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "renamed-owner/renamed-repository", + name: "renamed-repository", + owner: { login: "renamed-owner" }, + default_branch: "trunk", + }), + { status: 200 }, + ), + ) + } + if ( + url.endsWith("/repos/renamed-owner/renamed-repository/collaborators/octocat/permission") + ) { + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: 42 }, + permission, + role_name: roleName, + }), + { status: 200 }, + ), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + await expect( + service.evaluateAdmission({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }), + ).resolves.toMatchObject({ + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission, + prototypeRole, + admissionEligible: true, + }) + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/renamed-owner/renamed-repository/collaborators/octocat/permission", + ]) + }, + ) + + it("treats an immutable repository lookup 404 as definitive denial instead of cached access", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 404 }), + ), + ), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).resolves.toBeNull() + }) + + it("records source configuration mismatch as failed without refreshing confirmation", async () => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "99999", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "repository_source_configuration_changed", + lastConfirmedAt, + }) + }) + + it("records credential reconciliation failure without refreshing confirmation", async () => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "github_503", + lastConfirmedAt, + }) + }) + + it("relinks a repository without changing its existing admission switch", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "main", + admissionEnabled: true, + status: "removed", + removedAt: new Date(), + }, + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 100, + full_name: "new-owner/new-name", + name: "new-name", + owner: { login: "new-owner" }, + default_branch: "trunk", + }), + { status: 200 }, + ), + ), + ) + + await expect( + service.linkRepository("administrator-token", { githubRepositoryId: "100" }), + ).resolves.toMatchObject({ + id: source.id, + status: "active", + admissionEnabled: true, + owner: "new-owner", + name: "new-name", + }) + }) + + it("revokes synchronized access immediately for a deleted repository but preserves compatibility membership", async () => { + const { prisma, service, store } = await database() + const synchronized = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const compatibility = await createUser(store, { + subject: "43", + membershipSource: "legacy-synchronized", + token: "compatibility-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, synchronized.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + + await expect( + service.processWebhook( + webhook( + { + action: "deleted", + installation: { id: 7 }, + repository: { + id: 100, + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }, + }, + "repository", + "deleted-repository", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: synchronized.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect( + await prisma.installationMembership.findUnique({ + where: { id: compatibility.membership.id }, + }), + ).toMatchObject({ source: "legacy-synchronized", status: "active" }) + expect(await prisma.session.findUnique({ where: { id: synchronized.session!.id } })).toBeNull() + expect( + await prisma.session.findUnique({ where: { id: compatibility.session!.id } }), + ).not.toBeNull() + }) + + it("bounds permission requests and isolates an immutable identity mismatch to that identity", async () => { + const { prisma, service, store } = await database() + const users = [] + for (let index = 0; index < 12; index += 1) { + users.push(await createUser(store, { subject: String(1_000 + index) })) + } + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + let activeRequests = 0 + let maximumActiveRequests = 0 + let permissionRequestCount = 0 + const permissionGates: { resolve: () => void }[] = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + const subject = url.match(/collaborators\/user-(\d+)\/permission$/)?.[1] + if (!subject) throw new Error(`Unexpected request: ${url}`) + permissionRequestCount += 1 + activeRequests += 1 + maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests) + const gate = deferred() + permissionGates.push({ + resolve: () => { + activeRequests -= 1 + gate.resolve( + new Response( + JSON.stringify({ + user: { id: subject === "1000" ? 999_999 : Number(subject) }, + permission: "read", + }), + { status: 200 }, + ), + ) + }, + }) + return gate.promise + }), + ) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => { + expect(permissionRequestCount).toBe(10) + expect(activeRequests).toBe(10) + }) + expect(maximumActiveRequests).toBe(10) + permissionGates.slice(0, 10).forEach(({ resolve }) => resolve()) + await vi.waitFor(() => expect(permissionRequestCount).toBe(12)) + permissionGates.slice(10).forEach(({ resolve }) => resolve()) + await reconciliation + + expect(maximumActiveRequests).toBe(10) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: users[0]!.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + mappedPrototypeRole: null, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: users[1]!.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + admissionEligible: true, + mappedPrototypeRole: "viewer", + }) + }) + + it("does not overwrite an administrator suspension while reconciliation is waiting", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "target-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, target.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const targetPermission = deferred() + const targetPermissionRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + targetPermissionRequested.resolve() + return targetPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { + status: 200, + }), + ) + }), + ) + const administration = createAdministrationService({ store }) + + const reconciliation = service.reconcileDue() + await targetPermissionRequested.promise + await administration.changeMembership("administrator-token", target.membership.id, { + status: "suspended", + }) + targetPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + await reconciliation + + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "manual", status: "suspended" }) + expect(await prisma.session.findUnique({ where: { id: target.session!.id } })).toBeNull() + }) + + it("does not let an older reconciliation overwrite a fresh sign-in baseline", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const targetPermission = deferred() + const targetPermissionRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + targetPermissionRequested.resolve() + return targetPermission.promise + }), + ) + + const reconciliation = service.reconcileDue() + await targetPermissionRequested.promise + const currentSource = await prisma.gitHubRepositorySource.findUniqueOrThrow({ + where: { id: source.id }, + }) + const now = new Date() + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(now.getTime() + 60_000), + }), + ) + const freshBaseline = { + repositorySourceId: source.id, + sourceGeneration: currentSource.synchronizationGeneration, + rawPermission: "write" as const, + prototypeRole: "editor" as const, + admissionEligible: true, + confirmedAt: new Date(now.getTime() + 60_000), + } + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue(freshBaseline), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + now: () => now, + }) + + await expect(authentication.complete("code", "state", "browser-token")).resolves.toMatchObject({ + purpose: "sign-in", + }) + targetPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + await reconciliation + + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "active" }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + admissionEligible: true, + lastConfirmedAt: freshBaseline.confirmedAt, + }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(1) + }) + it("rolls back webhook mutations when the required revocation audit fails", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { @@ -741,6 +1482,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) await service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 73b997a1..874eaa94 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -32,7 +32,7 @@ const repositoryIdSchema = z.string().regex(/^[1-9]\d{0,19}$/) const identifierSchema = z.uuid() const linkSchema = z.object({ githubRepositoryId: repositoryIdSchema, - admissionEnabled: z.boolean().default(false), + admissionEnabled: z.boolean().optional(), }) const updateSchema = z.object({ admissionEnabled: z.boolean() }) const webhookSchema = z.object({ @@ -131,19 +131,37 @@ async function permissionFor( ), ) if (String(response.user.id) !== subject) { - throw new AuthenticationError( - 403, - "identity_mismatch", - "The repository permission identity did not match.", - ) + return "none" } - return response.permission + return normalizePermission(response.permission, response.role_name) } catch (error) { if (error instanceof GitHubApiError && error.status === 404) return "none" throw error } } +function normalizePermission( + permission: GitHubRepositoryPermission, + roleName?: string | null, +): GitHubRepositoryPermission { + if (roleName && ["admin", "maintain", "write", "triage", "read"].includes(roleName)) { + return roleName as GitHubRepositoryPermission + } + return permission +} + +async function mapWithConcurrency( + items: T[], + limit: number, + operation: (item: T) => Promise, +): Promise { + const results: R[] = [] + for (let index = 0; index < items.length; index += limit) { + results.push(...(await Promise.all(items.slice(index, index + limit).map(operation)))) + } + return results +} + async function lockRepository(database: Prisma.TransactionClient, id: string): Promise { await database.$queryRaw` SELECT "id" FROM "github_repository_sources" WHERE "id" = ${id}::uuid FOR UPDATE @@ -159,12 +177,17 @@ async function saveBaseline( await lockRepository(database, baseline.repositorySourceId) const source = await database.gitHubRepositorySource.findUnique({ where: { id: baseline.repositorySourceId }, - select: { status: true, admissionEnabled: true }, + select: { + status: true, + admissionEnabled: true, + synchronizationGeneration: true, + }, }) if ( !source || source.status !== "active" || - (requireAdmissionEnabled && !source.admissionEnabled) + (requireAdmissionEnabled && !source.admissionEnabled) || + source.synchronizationGeneration !== baseline.sourceGeneration ) { throw new AuthenticationError( 409, @@ -172,6 +195,22 @@ async function saveBaseline( "GitHub authorization changed. Start again.", ) } + const existing = await database.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + select: { lastConfirmedAt: true }, + }) + if (existing && existing.lastConfirmedAt > baseline.confirmedAt) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } await database.gitHubSynchronizedAccess.upsert({ where: { repositorySourceId_userId: { @@ -206,7 +245,7 @@ async function enforceSynchronizedMemberships( const cutoff = new Date(at.getTime() - (await lastKnownGoodSeconds(database)) * 1_000) const synchronizedMemberships = await database.installationMembership.findMany({ where: { source: "synchronized", status: "active" }, - select: { id: true, userId: true }, + select: { id: true, userId: true, role: true }, }) for (const membership of synchronizedMemberships) { const eligible = await database.gitHubSynchronizedAccess.findFirst({ @@ -219,10 +258,58 @@ async function enforceSynchronizedMemberships( }, }) if (!eligible) { - await database.installationMembership.update({ - where: { id: membership.id }, + const [workspaceAdministration, prototypeAdministration] = await Promise.all([ + database.workspaceAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + database.prototypeAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + ]) + if ( + membership.role === "administrator" || + workspaceAdministration || + prototypeAdministration + ) { + const preserved = await database.installationMembership.updateMany({ + where: { + id: membership.id, + source: "synchronized", + status: "active", + }, + data: { source: "manual" }, + }) + if (preserved.count > 0) { + await saveSecurityAudit(database, { + event: "membership.synchronized.preserve", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "manual_administration" }, + }) + } + continue + } + const removed = await database.installationMembership.updateMany({ + where: { + id: membership.id, + source: "synchronized", + status: "active", + }, data: { status: "removed", removedAt: at }, }) + if (removed.count === 0) continue await database.session.deleteMany({ where: { userId: membership.userId } }) await saveSecurityAudit(database, { event: "membership.synchronized.remove", @@ -241,6 +328,7 @@ async function removeRepositorySource( repositorySourceId: string, removedAt: Date, ): Promise { + await lockRepository(database, repositorySourceId) await database.gitHubRepositorySource.update({ where: { id: repositorySourceId }, data: { @@ -301,43 +389,51 @@ export function createGitHubAuthorizationService({ }, }) }) - const integration = await sourceCredentials() - if ( - integration.appId !== source.appId || - integration.installationId !== source.installationId - ) { - throw new AuthenticationError( - 409, - "repository_source_configuration_changed", - "The GitHub source integration configuration changed. Relink the repository.", - ) - } try { + const integration = await sourceCredentials() + if ( + integration.appId !== source.appId || + integration.installationId !== source.installationId + ) { + throw new AuthenticationError( + 409, + "repository_source_configuration_changed", + "The GitHub source integration configuration changed. Relink the repository.", + ) + } const token = await createInstallationToken(integration) const repository = repositorySchema.parse( await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), ) - const identities = await database.externalIdentity.findMany({ - where: { provider: "github", issuer: "https://github.com", username: { not: null } }, - select: { userId: true, subject: true, username: true }, - }) + const identities = [] + let identityCursor: string | undefined + while (true) { + const page = await database.externalIdentity.findMany({ + where: { provider: "github", issuer: "https://github.com", username: { not: null } }, + select: { id: true, userId: true, subject: true, username: true }, + orderBy: { id: "asc" }, + ...(identityCursor ? { cursor: { id: identityCursor }, skip: 1 } : {}), + take: 100, + }) + identities.push(...page) + if (page.length < 100) break + identityCursor = page.at(-1)?.id + } const confirmedAt = new Date() - const results = await Promise.all( - identities.map(async (identity) => { - const rawPermission = await permissionFor( - token, - identity.username!, - identity.subject, - repository.full_name, - ) - return { - userId: identity.userId, - rawPermission, - ...mapGitHubPermission(rawPermission), - } - }), - ) - const applied = await database.$transaction(async (transaction) => { + const results = await mapWithConcurrency(identities, 10, async (identity) => { + const rawPermission = await permissionFor( + token, + identity.username!, + identity.subject, + repository.full_name, + ) + return { + userId: identity.userId, + rawPermission, + ...mapGitHubPermission(rawPermission), + } + }) + const applied = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ where: { id: source.id }, @@ -369,6 +465,7 @@ export function createGitHubAuthorizationService({ for (const result of results) { await saveBaseline(transaction, result.userId, { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: result.rawPermission, admissionEligible: result.admissionEligible, prototypeRole: result.prototypeRole, @@ -403,7 +500,7 @@ export function createGitHubAuthorizationService({ throw error } if (error instanceof GitHubApiError && error.status === 404) { - const removed = await database.$transaction(async (transaction) => { + const removed = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ where: { id: source.id }, @@ -432,7 +529,11 @@ export function createGitHubAuthorizationService({ data: { synchronizationStatus: "failed", synchronizationError: - error instanceof GitHubApiError ? `github_${error.status}` : "provider_unavailable", + error instanceof GitHubApiError + ? `github_${error.status}` + : error instanceof AuthenticationError + ? error.code + : "provider_unavailable", }, }) if (failed.count === 0) { @@ -493,6 +594,7 @@ export function createGitHubAuthorizationService({ const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) return { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: baseline.rawPermission as GitHubRepositoryPermission, ...mapped, confirmedAt: baseline.lastConfirmedAt, @@ -517,16 +619,26 @@ export function createGitHubAuthorizationService({ continue } try { + let repository: z.infer + try { + repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) continue + throw error + } const rawPermission = await permissionFor( token, profile.username, profile.subject, - `${source.owner}/${source.name}`, + repository.full_name, ) const mapped = mapGitHubPermission(rawPermission) if (mapped.admissionEligible) { return { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission, ...mapped, confirmedAt: new Date(), @@ -540,6 +652,7 @@ export function createGitHubAuthorizationService({ const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) return { repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, rawPermission: baseline.rawPermission as GitHubRepositoryPermission, ...mapped, confirmedAt: baseline.lastConfirmedAt, @@ -608,6 +721,28 @@ export function createGitHubAuthorizationService({ } }, + async prototypeWorkspaceIds(transaction, userId, now = new Date()) { + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const prototypes = await transaction.prototype.findMany({ + where: { + authorizationMode: "github-synchronized", + sourceRepository: { + status: "active", + synchronizedAccess: { + some: { + userId, + status: "active", + mappedPrototypeRole: { not: null }, + lastConfirmedAt: { gte: cutoff }, + }, + }, + }, + }, + select: { workspaceId: true }, + }) + return [...new Set(prototypes.map(({ workspaceId }) => workspaceId))] + }, + async listRepositories(sessionToken) { await currentAdministrator(store, sessionToken) return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) @@ -639,7 +774,7 @@ export function createGitHubAuthorizationService({ owner: repository.owner.login, name: repository.name, defaultBranch: repository.default_branch, - admissionEnabled: parsed.data.admissionEnabled, + admissionEnabled: parsed.data.admissionEnabled ?? false, dirtyAt: new Date(), }, update: { @@ -650,7 +785,9 @@ export function createGitHubAuthorizationService({ defaultBranch: repository.default_branch, status: "active", removedAt: null, - admissionEnabled: parsed.data.admissionEnabled, + ...(parsed.data.admissionEnabled === undefined + ? {} + : { admissionEnabled: parsed.data.admissionEnabled }), synchronizationGeneration: { increment: 1 }, synchronizationStatus: "dirty", dirtyAt: new Date(), @@ -766,7 +903,7 @@ export function createGitHubAuthorizationService({ // The source record stores the explicit failure and bounded last-known-good state. } } - await database.$transaction((transaction) => + await store.lock((_records, transaction) => enforceSynchronizedMemberships(transaction, new Date()), ) }, @@ -827,7 +964,7 @@ export function createGitHubAuthorizationService({ ]) if (!relevant.has(event)) return "ignored" try { - return await database.$transaction(async (transaction) => { + return await store.lock(async (_records, transaction) => { const repositoryId = payload.data.repository?.id const source = repositoryId ? await transaction.gitHubRepositorySource.findUnique({ @@ -858,6 +995,9 @@ export function createGitHubAuthorizationService({ } else { const dirtyIds = new Set() const removedIds = new Set() + if (event === "repository" && payload.data.action === "deleted" && repositoryId) { + removedIds.add(String(repositoryId)) + } for (const repository of payload.data.repositories_added ?? []) dirtyIds.add(String(repository.id)) for (const repository of payload.data.repositories_removed ?? []) diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts index d9091158..faf87aad 100644 --- a/hub/server/domains/github-authorization/create-service/types.ts +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -1,9 +1,10 @@ import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" -import type { Prisma } from "../../../generated/prisma/client.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" import type { GitHubRepositoryPermission, MappedGitHubPermission } from "../map-permission/index.ts" export interface GitHubAdmissionBaseline extends MappedGitHubPermission { repositorySourceId: string + sourceGeneration: number rawPermission: GitHubRepositoryPermission confirmedAt: Date } @@ -25,11 +26,16 @@ export interface GitHubAuthorizationService { baseline: GitHubAdmissionBaseline, ): Promise prototypeBaseline( - database: Prisma.TransactionClient, + database: Prisma.TransactionClient | PrismaClient, userId: string, prototypeId: string, now?: Date, ): Promise + prototypeWorkspaceIds( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + now?: Date, + ): Promise listRepositories(sessionToken: string): Promise linkRepository(sessionToken: string, input: unknown): Promise updateRepository( diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index ff60c488..d4659854 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -30,10 +30,12 @@ async function createActor( subject, token, role = "member", + source = "manual", }: { subject: string token: string role?: "member" | "administrator" + source?: "manual" | "synchronized" }, ) { const authorizedAt = new Date() @@ -65,7 +67,7 @@ async function createActor( userId: user.id, status: "active", role, - source: "manual", + source, }) const session = await records.createSession({ userId: user.id, @@ -1582,12 +1584,19 @@ describe("createPrototypeAuthorizationService() database behavior", () => { limit: 20, }), ).resolves.toEqual({ items: [], nextCursor: null }) - expect(baselineAccess.mock.calls).toContainEqual([actor.user.id, prototype.id]) + expect(baselineAccess.mock.calls).toContainEqual([prisma, actor.user.id, prototype.id]) }) it("uses baseline authorization for single-prototype reads and role-gated mutations", async () => { - const baselineRole = vi.fn().mockResolvedValue("administrator") - const { prisma, service, store } = await database({ role: baselineRole }) + const baselineAccess = vi.fn().mockResolvedValue({ + role: "administrator", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "admin", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) const actor = await createActor(store, { subject: "baseline-single-prototype", token: "baseline-single-prototype-token", @@ -1637,7 +1646,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ).resolves.toMatchObject({ allowed: true, role: "administrator", - source: "baseline", + source: "github-synchronized", }) await expect( service.listVersions("baseline-single-prototype-token", prototype.id, {}), @@ -1669,10 +1678,11 @@ describe("createPrototypeAuthorizationService() database behavior", () => { )) as { archivedAt: Date | null } expect(archived.archivedAt).toBeInstanceOf(Date) - expect(baselineRole.mock.calls.length).toBeGreaterThanOrEqual(8) + expect(baselineAccess.mock.calls.length).toBeGreaterThanOrEqual(8) expect( - baselineRole.mock.calls.every( - ([userId, prototypeId]) => userId === actor.user.id && prototypeId === prototype.id, + baselineAccess.mock.calls.every( + ([database, userId, prototypeId]) => + database && userId === actor.user.id && prototypeId === prototype.id, ), ).toBe(true) expect( @@ -1688,10 +1698,10 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) it("short-circuits the baseline for explicit prototype roles and denies", async () => { - const baselineRole = vi - .fn() + const baselineAccess = vi + .fn() .mockRejectedValue(new Error("baseline unavailable")) - const { prisma, service, store } = await database({ role: baselineRole }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) const actor = await createActor(store, { subject: "single-prototype-short-circuit", token: "single-prototype-short-circuit-token", @@ -1747,7 +1757,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { allowed: false, source: "hub-prototype", }) - expect(baselineRole).not.toHaveBeenCalled() + expect(baselineAccess).not.toHaveBeenCalled() }) it("lets a workspace deny override prototype and baseline access when listing", async () => { @@ -1853,6 +1863,173 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(baselineAccess.mock.calls).toHaveLength(0) }) + it("makes synchronized memberships manual when granting workspace or prototype administration", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "administration-actor", + token: "administration-actor-token", + role: "administrator", + }) + const workspaceTarget = await createActor(store, { + subject: "workspace-administrator-target", + token: "workspace-administrator-target-token", + source: "synchronized", + }) + const prototypeTarget = await createActor(store, { + subject: "prototype-administrator-target", + token: "prototype-administrator-target-token", + source: "synchronized", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + + await service.setWorkspaceAccess( + "administration-actor-token", + workspace.id, + workspaceTarget.user.id, + { effect: "allow", role: "administrator" }, + ) + await service.setPrototypeAccess( + "administration-actor-token", + prototype.id, + prototypeTarget.user.id, + { effect: "allow", role: "administrator" }, + ) + + expect( + await prisma.installationMembership.findUnique({ + where: { userId: workspaceTarget.user.id }, + }), + ).toMatchObject({ source: "manual", status: "active" }) + expect( + await prisma.installationMembership.findUnique({ + where: { userId: prototypeTarget.user.id }, + }), + ).toMatchObject({ source: "manual", status: "active" }) + }) + + it("passes the mutation transaction to a synchronized prototype baseline", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "editor", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "write", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + const actor = await createActor(store, { + subject: "transaction-baseline", + token: "transaction-baseline-token", + }) + const workspace = await prisma.workspace.create({ + data: { name: "Workspace", createdByUserId: actor.user.id }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + + await expect( + service.createVersion("transaction-baseline-token", prototype.id, { + label: "Baseline version", + }), + ).resolves.toMatchObject({ prototypeId: prototype.id, sequence: 1 }) + + expect(baselineAccess).toHaveBeenCalledOnce() + expect(baselineAccess.mock.calls[0]?.slice(1)).toEqual([actor.user.id, prototype.id]) + expect(baselineAccess.mock.calls[0]?.[0]).not.toBe(prisma) + }) + + it("does not evaluate prototype baselines in unrelated workspaces while listing workspaces", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const baselineWorkspaceIds = vi.fn>() + const { prisma, service, store } = await database({ + baseline: baselineAccess, + workspaceIds: baselineWorkspaceIds, + }) + const actor = await createActor(store, { + subject: "workspace-baseline-scope", + token: "workspace-baseline-scope-token", + }) + const accessibleWorkspace = await prisma.workspace.create({ + data: { + name: "Accessible workspace", + createdByUserId: actor.user.id, + prototypes: { + create: { + name: "Accessible prototype", + createdByUserId: actor.user.id, + }, + }, + }, + include: { prototypes: true }, + }) + await Promise.all( + Array.from({ length: 5 }, (_, index) => + prisma.workspace.create({ + data: { + name: `Unrelated workspace ${index}`, + createdByUserId: actor.user.id, + prototypes: { + create: { + name: `Unrelated prototype ${index}`, + createdByUserId: actor.user.id, + }, + }, + }, + }), + ), + ) + baselineWorkspaceIds.mockResolvedValue([accessibleWorkspace.id]) + + await expect( + service.listWorkspaces("workspace-baseline-scope-token", {}), + ).resolves.toMatchObject({ + items: [{ id: accessibleWorkspace.id }], + nextCursor: null, + }) + expect(baselineWorkspaceIds).toHaveBeenCalledWith(prisma, actor.user.id) + expect(baselineAccess.mock.calls).toEqual([ + [prisma, actor.user.id, accessibleWorkspace.prototypes[0]!.id], + ]) + }) + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 64281407..815f4234 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -156,6 +156,18 @@ async function requireActiveTarget(database: Prisma.TransactionClient, userId: s } } +async function preserveManualAdministration( + database: Prisma.TransactionClient, + userId: string, + change: GrantInput, +): Promise { + if (change.effect !== "allow" || change.role !== "administrator") return + await database.installationMembership.updateMany({ + where: { userId, source: "synchronized", status: "active" }, + data: { source: "manual" }, + }) +} + async function lockWorkspace( database: Prisma.TransactionClient, workspaceId: string, @@ -231,14 +243,16 @@ async function prototypeDecision( if (!prototype) { throw new AuthenticationError(404, "prototype_not_found", "The prototype was not found.") } + const decision = await prototypeAccessDecision( + baseline, + database, + userId, + prototype.workspace.grants[0] ?? null, + prototype, + ) return { prototype, - decision: await prototypeAccessDecision( - baseline, - userId, - prototype.workspace.grants[0] ?? null, - prototype, - ), + decision, } } @@ -340,6 +354,7 @@ function prototypeDto(prototype: { async function prototypeAccessDecision( baseline: PrototypeBaselinePolicy | null, + database: Prisma.TransactionClient | PrismaClient, userId: string, workspaceGrant: GrantInput | null, prototype: { @@ -353,7 +368,7 @@ async function prototypeAccessDecision( }) if (hubDecision.source !== "none" || !baseline) return hubDecision return evaluatePrototypeAccess({ - baseline: await baseline.baseline(userId, prototype.id), + baseline: await baseline.baseline(database, userId, prototype.id), }) } @@ -370,10 +385,20 @@ async function readAccessibleWorkspacePage({ cursor?: string limit: number }) { + const baselineWorkspaceIds = baseline?.workspaceIds + ? await baseline.workspaceIds(database, userId) + : [] const items = [] let scanCursor = cursor while (items.length <= limit) { const candidates = await database.workspace.findMany({ + where: { + OR: [ + { grants: { some: { userId } } }, + { prototypes: { some: { grants: { some: { userId } } } } }, + ...(baselineWorkspaceIds.length > 0 ? [{ id: { in: baselineWorkspaceIds } }] : []), + ], + }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), @@ -452,7 +477,13 @@ async function readAccessiblePrototypePage({ if (candidates.length === 0) break for (const candidate of candidates) { scanCursor = candidate.id - const decision = await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate) + const decision = await prototypeAccessDecision( + baseline, + database, + userId, + workspaceGrant, + candidate, + ) if (decision.allowed) items.push(prototypeDto(candidate)) if (items.length > limit) break } @@ -496,7 +527,10 @@ async function hasAnyPrototypeAccess({ take: 100, }) for (const candidate of candidates) { - if ((await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate)).allowed) { + if ( + (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) + .allowed + ) { return true } } @@ -635,6 +669,7 @@ export function createPrototypeAuthorizationService({ create: { workspaceId, userId: targetId, ...change }, update: change, }) + await preserveManualAdministration(transaction, targetUserId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", workspaceId, @@ -776,7 +811,6 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "editor") if (prototype.archivedAt) { @@ -820,10 +854,10 @@ export function createPrototypeAuthorizationService({ requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") if (parsed.data.mode === "github-synchronized") { @@ -880,7 +914,6 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") await requireActiveTarget(transaction, targetId) @@ -896,6 +929,7 @@ export function createPrototypeAuthorizationService({ create: { prototypeId, userId: targetId, ...change }, update: change, }) + await preserveManualAdministration(transaction, targetId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant", workspaceId: prototype.workspaceId, @@ -923,7 +957,6 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "administrator") if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, null))) { @@ -982,7 +1015,6 @@ export function createPrototypeAuthorizationService({ transaction, actor.userId, prototypeId, - baseline, ) requireDecision(decision, "editor") if (prototype.archivedAt) { diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index 8419000a..13cfc46f 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -1,4 +1,5 @@ import type { AccessDecision, AccessInput, PrototypeRole } from "../evaluate-access/types.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" export interface Page { items: T[] @@ -52,5 +53,13 @@ export interface GrantInput { } export interface PrototypeBaselinePolicy { - baseline(userId: string, prototypeId: string): Promise + baseline( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + prototypeId: string, + ): Promise + workspaceIds?( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + ): Promise } diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts index 8fcb6b80..59f639e3 100644 --- a/hub/server/integrations/github/check-repository-admission/constants.ts +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -16,6 +16,7 @@ export const installedRepositoriesSchema = z.object({ export const collaboratorPermissionSchema = z.object({ user: z.object({ id: z.number().int().positive() }), permission: z.enum(["admin", "maintain", "write", "triage", "read", "none"]), + role_name: z.string().min(1).max(255).nullable().optional(), }) export const repositoryAdmissionBatchSize = 10 diff --git a/hub/server/repositories/installation-memberships/types.ts b/hub/server/repositories/installation-memberships/types.ts index 4d09271e..0ca3d4ef 100644 --- a/hub/server/repositories/installation-memberships/types.ts +++ b/hub/server/repositories/installation-memberships/types.ts @@ -2,7 +2,8 @@ export interface InstallationMembershipInput { userId: string status: "active" | "suspended" | "removed" role: "member" | "administrator" - source: "bootstrap" | "invitation" | "manual" | "recovery" | "synchronized" + source: + "bootstrap" | "invitation" | "legacy-synchronized" | "manual" | "recovery" | "synchronized" suspendedAt?: Date | null removedAt?: Date | null } From e020f106231119f22c121082072e73faa4eba6f3 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 15:54:36 -0700 Subject: [PATCH 04/12] fix(hub): close synchronized authorization gaps Backfill compatibility memberships, persist negative confirmations, enforce synchronized session freshness, and align reconciliation, invitation, listing, and workspace-read behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 6 +- hub/docs/prototype-authorization.md | 2 +- hub/docs/testing-and-operations.md | 2 +- .../migration.sql | 18 + .../create-service/index.test.ts | 106 +++++- .../get-current-session/index.ts | 6 + .../create-service/index.db.test.ts | 343 +++++++++++++----- .../create-service/index.ts | 142 +++++--- .../domains/identity/create-records/index.ts | 2 + .../domains/identity/create-store/types.ts | 1 + .../create-service/index.db.test.ts | 73 ++++ .../create-service/index.ts | 13 +- .../has-fresh-admission/index.ts | 27 ++ 13 files changed, 590 insertions(+), 151 deletions(-) create mode 100644 hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index a2010834..79fd44b3 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -37,7 +37,9 @@ Each reconciliation claims a monotonically increasing source generation while ho ## Staleness and outages -A successful confirmation records `lastConfirmedAt`. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 suppresses that source's old cache, while an outage may use only the still-fresh cache for the same source. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. +A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 suppresses that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. + +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. @@ -51,4 +53,4 @@ Manual Hub grants, denies, membership assignments, and suspensions are separate - `PUT /api/prototypes/:prototypeId/authorization` - `GET /api/prototypes/:prototypeId/access/explanation` -Administration mutations require an active installation administrator, fresh verification, the configured origin, and matching CSRF cookie and header. Product policy changes require a fresh effective prototype administrator. Request bodies are omitted from logs, responses are private and non-cacheable, and mutations write structured audits in the same transaction. +Administration mutations require an active installation administrator, fresh verification, the configured origin, and matching CSRF cookie and header. Repository listing requires the active administrator role but not a fresh verification because it is read-only. Product policy changes require a fresh effective prototype administrator. Request bodies are omitted from logs, responses are private and non-cacheable, and mutations write structured audits in the same transaction. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index ee0131f7..acad32eb 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -56,7 +56,7 @@ Workspace administrators cannot remove or replace the final active workspace adm Suspending or removing a membership fails with `product_administration_orphaned` when that user is the final active administrator for a workspace or prototype. An administrator must transfer product administration first. Changing an active installation administrator to a member does not remove an explicit product role, because installation and product authority remain separate. -A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. Workspace list responses contain only workspace fields and never embed prototype or access-rule metadata. +A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. A workspace reachable through a direct prototype rule or synchronized prototype baseline is also readable, unless an explicit workspace deny blocks it. Workspace list responses contain only workspace fields and never embed prototype or access-rule metadata. ## API and request boundaries diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index d413c1d4..f7951c0c 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -59,7 +59,7 @@ A dedicated migration case applies the foundation migration, seeds retained life Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, immutable-ID rename resolution, repository and installation removal, permission and identity mismatch, built-in role-name mapping, provider and credential outage, per-repository cache fallback, dirty and deletion webhooks, replayed deliveries, missed-event repair, bounded request concurrency, stale overlapping reconciliation, unlink during reconciliation, manual suspension and administration preservation, compatibility membership isolation, synchronized session revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence, immutable-ID rename resolution, repository and installation removal, positive and negative permission confirmation, identity mismatch, built-in role-name mapping, provider and credential outage, App/installation-aware cache fallback, dirty and deletion webhooks, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, stale per-user and overlapping reconciliation, unlink during reconciliation, manual suspension and administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql index f7118122..b5941705 100644 --- a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -14,6 +14,24 @@ CREATE TABLE "github_authorization_policies" ( INSERT INTO "github_authorization_policies" ("id") VALUES ('00000000-0000-0000-0000-000000000001'); +ALTER TABLE "installation_memberships" +DROP CONSTRAINT "installation_memberships_source"; + +ALTER TABLE "installation_memberships" +ADD CONSTRAINT "installation_memberships_source" +CHECK ("source" IN ( + 'bootstrap', + 'invitation', + 'legacy-synchronized', + 'manual', + 'recovery', + 'synchronized' +)); + +UPDATE "installation_memberships" +SET "source" = 'legacy-synchronized' +WHERE "source" = 'synchronized'; + -- CreateTable CREATE TABLE "github_repository_sources" ( "id" UUID NOT NULL, diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index 6ff60b90..f5468717 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -250,7 +250,7 @@ describe("createAuthenticationService()", () => { ) }) - it("activates only an exact provider identity invitation before issuing a session", async () => { + it("uses an exact invitation before a qualifying synchronized baseline for an inactive identity", async () => { const existingIdentity = { id: "identity", userId: "user", @@ -301,10 +301,19 @@ describe("createAuthenticationService()", () => { freshAuthorizedAt: now, }), }) + const saveBaseline = vi.fn() const options = dependencies(identityRecords, { admissionPolicy: { - name: "hub-managed", - admit: vi.fn().mockResolvedValue(false), + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline, }, }) options.github.identify.mockResolvedValue({ @@ -334,6 +343,97 @@ describe("createAuthenticationService()", () => { "membership", now, ) + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + role: "administrator", + source: "invitation", + }), + ) + expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) + }) + + it("uses an invitation before a qualifying synchronized baseline for a new identity", async () => { + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "administrator" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const identity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation), + createUser: vi.fn().mockResolvedValue(identity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(identity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const saveBaseline = vi.fn() + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + rawPermission: "read", + admissionEligible: true, + prototypeRole: "viewer", + confirmedAt: now, + }), + saveBaseline, + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + role: "administrator", + source: "invitation", + }), + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) }) it("admits an active Hub member without consulting legacy repository policy", async () => { diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts index 2ec3974b..d023f7ae 100644 --- a/hub/server/domains/authentication/get-current-session/index.ts +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -16,5 +16,11 @@ export async function getCurrentSession( if (!membership || membership.status !== "active") { throw new AuthenticationError(401, "membership_inactive", "Sign in to continue.") } + if ( + membership.source === "synchronized" && + !(await records.hasFreshGitHubAdmission(session.userId, dependencies.now())) + ) { + throw new AuthenticationError(401, "membership_inactive", "Sign in to continue.") + } return { ...session, membership } } diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 00bc4c5a..7a20a839 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -26,7 +26,7 @@ function environment() { } } -async function database() { +async function database(environmentOverrides: NodeJS.ProcessEnv = {}) { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") onTestFinished(() => prisma.$disconnect()) @@ -36,7 +36,7 @@ async function database() { store, service: createGitHubAuthorizationService({ database: prisma, - environment: environment(), + environment: { ...environment(), ...environmentOverrides }, store, }), } @@ -228,17 +228,31 @@ describe("createGitHubAuthorizationService() database behavior", () => { it("removes synchronized state for a repository webhook while preserving manual rules", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) - const source = await prisma.gitHubRepositorySource.create({ - data: { - githubRepositoryId: "100", - appId: "12345", - installationId: "7", - owner: "owner", - name: "repository", - defaultBranch: "main", - admissionEnabled: true, - }, - }) + const [source, unrelatedSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "unrelated", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt: new Date("2026-10-28T18:00:00.000Z"), + }, + }), + ]) const workspace = await prisma.workspace.create({ data: { name: "Workspace", @@ -295,6 +309,13 @@ describe("createGitHubAuthorizationService() database behavior", () => { where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, }), ).toMatchObject({ effect: "allow", role: "viewer" }) + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: unrelatedSource.id } }), + ).toMatchObject({ + synchronizationGeneration: unrelatedSource.synchronizationGeneration, + synchronizationStatus: "current", + dirtyAt: null, + }) expect(await prisma.gitHubWebhookDelivery.count()).toBe(1) }) @@ -487,9 +508,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ effect: "allow", role: "commenter" }) }) - it("keeps the newer result when overlapping reconciliations finish out of order", async () => { + it("does not start another scheduled reconciliation while one is running", async () => { const { prisma, service, store } = await database() - const actor = await createUser(store, { subject: "42" }) + await createUser(store, { subject: "42" }) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -501,19 +522,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - const workspace = await prisma.workspace.create({ - data: { - name: "Workspace", - createdByUserId: actor.user.id, - grants: { - create: { userId: actor.user.id, effect: "deny", role: null }, - }, - }, - }) - const firstRepositoryResponse = deferred() - const secondRepositoryResponse = deferred() - const firstRepositoryRequested = deferred() - const secondRepositoryRequested = deferred() + const repositoryResponse = deferred() + const repositoryRequested = deferred() let repositoryRequests = 0 vi.stubGlobal( "fetch", @@ -526,89 +536,49 @@ describe("createGitHubAuthorizationService() database behavior", () => { } if (url.endsWith("/repositories/100")) { repositoryRequests += 1 - if (repositoryRequests === 1) { - firstRepositoryRequested.resolve() - return firstRepositoryResponse.promise - } - secondRepositoryRequested.resolve() - return secondRepositoryResponse.promise + repositoryRequested.resolve() + return repositoryResponse.promise } - const permission = url.includes("newer-owner") ? "read" : "write" return Promise.resolve( - new Response(JSON.stringify({ user: { id: 42 }, permission }), { status: 200 }), + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), ) }), ) - const olderReconciliation = service.reconcileDue() - await firstRepositoryRequested.promise - const newerReconciliation = service.reconcileDue() - await secondRepositoryRequested.promise - secondRepositoryResponse.resolve( - new Response( - JSON.stringify({ - id: 100, - full_name: "newer-owner/newer-name", - name: "newer-name", - owner: { login: "newer-owner" }, - default_branch: "newer-branch", - }), - { status: 200 }, - ), - ) - await newerReconciliation - firstRepositoryResponse.resolve( + const firstReconciliation = service.reconcileDue() + await repositoryRequested.promise + + await expect(service.reconcileDue()).resolves.toBeUndefined() + expect(repositoryRequests).toBe(1) + + repositoryResponse.resolve( new Response( JSON.stringify({ - id: 100, - full_name: "older-owner/older-name", - name: "older-name", - owner: { login: "older-owner" }, - default_branch: "older-branch", + id: Number(source.githubRepositoryId), + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", }), { status: 200 }, ), ) - await olderReconciliation + await firstReconciliation expect( await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), ).toMatchObject({ - synchronizationGeneration: 2, synchronizationStatus: "current", - owner: "newer-owner", - name: "newer-name", - defaultBranch: "newer-branch", - }) - expect( - await prisma.gitHubSynchronizedAccess.findUnique({ - where: { - repositorySourceId_userId: { - repositorySourceId: source.id, - userId: actor.user.id, - }, - }, - }), - ).toMatchObject({ - rawPermission: "read", - mappedPrototypeRole: "viewer", - status: "active", + synchronizationGeneration: 1, }) - expect( - await prisma.workspaceAccessGrant.findUnique({ - where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, - }), - ).toMatchObject({ effect: "deny", role: null }) - expect( - await prisma.securityAudit.count({ - where: { event: "github.repository.reconcile", githubRepositorySourceId: source.id }, - }), - ).toBe(1) }) - it("does not use one repository's cached grant when another repository fails", async () => { + it("persists a live denial so a later outage cannot reuse the old positive cache", async () => { const { prisma, service, store } = await database() - const actor = await createUser(store, { subject: "42" }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) const [sourceA, sourceB] = await Promise.all([ prisma.gitHubRepositorySource.create({ data: { @@ -692,7 +662,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { displayName: actor.user.displayName, avatarUrl: null, }), - ).resolves.toBeNull() + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) expect( await prisma.gitHubSynchronizedAccess.findUnique({ @@ -703,8 +676,36 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }, }), - ).toMatchObject({ rawPermission: "write", status: "active" }) + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + mappedPrototypeRole: null, + status: "removed", + }) expect(sourceB.id).not.toBe(sourceA.id) + + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 503 }), + ), + ), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) }) it("uses a fresh cached grant only when that same repository's live check fails", async () => { @@ -758,6 +759,95 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it.each([ + { appId: "99999", installationId: "7", mismatch: "App" }, + { appId: "12345", installationId: "8", mismatch: "installation" }, + ])( + "does not use cached access after token creation fails when the source $mismatch ID differs", + async ({ appId, installationId }) => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId, + installationId, + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + }, + ) + + it.each([ + { membershipSource: "manual", expected: "preserved" }, + { membershipSource: "synchronized", expected: "closed" }, + ] as const)( + "fails $expected when stored source credentials cannot be decrypted for a $membershipSource membership", + async ({ membershipSource }) => { + const { prisma, service, store } = await database({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "database", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const actor = await createUser(store, { subject: "42", membershipSource }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await store.lock((records) => + records.saveGitHubIntegrationCredentials("invalid-encrypted-credentials"), + ) + const admission = service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + + if (membershipSource === "manual") { + await expect(admission).resolves.toBeNull() + } else { + await expect(admission).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + } + }, + ) + it("rejects baseline saves from a stale source generation or older confirmation", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) @@ -1018,6 +1108,33 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("lists repository sources for an administrator without fresh verification", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + await prisma.session.update({ + where: { id: administrator.session!.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 10 * 60_000) }, + }) + + await expect(service.listRepositories("administrator-token")).resolves.toEqual([ + expect.objectContaining({ id: source.id }), + ]) + }) + it("relinks a repository without changing its existing admission switch", async () => { const { prisma, service, store } = await database() await createUser(store, { @@ -1332,13 +1449,18 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.findUnique({ where: { id: target.session!.id } })).toBeNull() }) - it("does not let an older reconciliation overwrite a fresh sign-in baseline", async () => { + it("skips a superseded baseline while still applying another user's permission loss", async () => { const { prisma, service, store } = await database() const target = await createUser(store, { subject: "42", membershipSource: "synchronized", status: "removed", }) + const lost = await createUser(store, { + subject: "43", + membershipSource: "synchronized", + token: "lost-token", + }) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -1350,6 +1472,14 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) + await service.saveAdmissionBaseline(prisma, lost.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) const targetPermission = deferred() const targetPermissionRequested = deferred() vi.stubGlobal( @@ -1375,8 +1505,13 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } - targetPermissionRequested.resolve() - return targetPermission.promise + if (url.includes("user-42")) { + targetPermissionRequested.resolve() + return targetPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 43 }, permission: "none" }), { status: 200 }), + ) }), ) @@ -1460,6 +1595,24 @@ describe("createGitHubAuthorizationService() database behavior", () => { lastConfirmedAt: freshBaseline.confirmedAt, }) expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(1) + expect( + await prisma.installationMembership.findUnique({ where: { id: lost.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: lost.session!.id } })).toBeNull() + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: lost.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) }) it("rolls back webhook mutations when the required revocation audit fails", async () => { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 874eaa94..221e10ec 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -78,6 +78,7 @@ async function currentAdministrator( store: IdentityStore, sessionToken: string, database?: Prisma.TransactionClient, + requireFresh = true, ) { const actor = await getCurrentSession( authenticationDependencies(store), @@ -86,8 +87,8 @@ async function currentAdministrator( ) if ( actor.membership.role !== "administrator" || - !actor.freshAuthorizedAt || - Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000 + (requireFresh && + (!actor.freshAuthorizedAt || Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000)) ) { throw new AuthenticationError( 403, @@ -173,7 +174,8 @@ async function saveBaseline( userId: string, baseline: GitHubAdmissionBaseline, requireAdmissionEnabled = false, -) { + skipOlderConfirmation = false, +): Promise<"saved" | "superseded"> { await lockRepository(database, baseline.repositorySourceId) const source = await database.gitHubRepositorySource.findUnique({ where: { id: baseline.repositorySourceId }, @@ -205,6 +207,7 @@ async function saveBaseline( select: { lastConfirmedAt: true }, }) if (existing && existing.lastConfirmedAt > baseline.confirmedAt) { + if (skipOlderConfirmation) return "superseded" throw new AuthenticationError( 409, "github_authorization_changed", @@ -236,6 +239,7 @@ async function saveBaseline( removedAt: baseline.admissionEligible ? null : baseline.confirmedAt, }, }) + return "saved" } async function enforceSynchronizedMemberships( @@ -359,6 +363,7 @@ export function createGitHubAuthorizationService({ if (webhookSecret && (webhookSecret.length < 20 || webhookSecret.length > 1024)) { throw new Error("HUB_GITHUB_WEBHOOK_SECRET must contain between 20 and 1024 characters.") } + let reconciliationDueRunning = false async function sourceCredentials() { return credentials(store, createGitHubIntegrationCredentialProvider(environment)) @@ -463,14 +468,20 @@ export function createGitHubAuthorizationService({ }, }) for (const result of results) { - await saveBaseline(transaction, result.userId, { - repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, - rawPermission: result.rawPermission, - admissionEligible: result.admissionEligible, - prototypeRole: result.prototypeRole, - confirmedAt, - }) + await saveBaseline( + transaction, + result.userId, + { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: result.rawPermission, + admissionEligible: result.admissionEligible, + prototypeRole: result.prototypeRole, + confirmedAt, + }, + false, + true, + ) } await enforceSynchronizedMemberships(transaction, confirmedAt) await saveSecurityAudit(transaction, { @@ -587,17 +598,26 @@ export function createGitHubAuthorizationService({ integration = await sourceCredentials() token = await createInstallationToken(integration) } catch (error) { - if (!(error instanceof GitHubApiError)) throw error - for (const source of sources) { - const baseline = cachedBySource.get(source.id) - if (baseline) { - const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) - return { - repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, - rawPermission: baseline.rawPermission as GitHubRepositoryPermission, - ...mapped, - confirmedAt: baseline.lastConfirmedAt, + if (error instanceof GitHubApiError) { + for (const source of sources) { + if ( + source.appId !== integration!.appId || + source.installationId !== integration!.installationId + ) { + continue + } + const baseline = cachedBySource.get(source.id) + if (baseline) { + const mapped = mapGitHubPermission( + baseline.rawPermission as GitHubRepositoryPermission, + ) + return { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } } } } @@ -644,6 +664,17 @@ export function createGitHubAuthorizationService({ confirmedAt: new Date(), } } + if (identity) { + await store.lock((_records, transaction) => + saveBaseline(transaction, identity.userId, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission, + ...mapped, + confirmedAt: new Date(), + }), + ) + } } catch (error) { if (!(error instanceof GitHubApiError)) throw error providerUnavailable = true @@ -673,8 +704,9 @@ export function createGitHubAuthorizationService({ return null }, - saveAdmissionBaseline: (transaction, userId, baseline) => - saveBaseline(transaction, userId, baseline, true), + async saveAdmissionBaseline(transaction, userId, baseline) { + await saveBaseline(transaction, userId, baseline, true) + }, async prototypeBaseline( transaction, @@ -744,7 +776,7 @@ export function createGitHubAuthorizationService({ }, async listRepositories(sessionToken) { - await currentAdministrator(store, sessionToken) + await currentAdministrator(store, sessionToken, undefined, false) return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) }, @@ -892,20 +924,26 @@ export function createGitHubAuthorizationService({ }, async reconcileDue() { - const sources = await database.gitHubRepositorySource.findMany({ - where: { status: "active" }, - select: { id: true }, - }) - for (const source of sources) { - try { - await reconcile(source.id) - } catch { - // The source record stores the explicit failure and bounded last-known-good state. + if (reconciliationDueRunning) return + reconciliationDueRunning = true + try { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active" }, + select: { id: true }, + }) + for (const source of sources) { + try { + await reconcile(source.id) + } catch { + // The source record stores the explicit failure and bounded last-known-good state. + } } + await store.lock((_records, transaction) => + enforceSynchronizedMemberships(transaction, new Date()), + ) + } finally { + reconciliationDueRunning = false } - await store.lock((_records, transaction) => - enforceSynchronizedMemberships(transaction, new Date()), - ) }, async processWebhook({ body, deliveryId, event, signature }) { @@ -1012,17 +1050,25 @@ export function createGitHubAuthorizationService({ await removeRepositorySource(transaction, source.id, now) } } - await transaction.gitHubRepositorySource.updateMany({ - where: - dirtyIds.size > 0 - ? { githubRepositoryId: { in: [...dirtyIds] }, status: "active" } - : { installationId: integration.installationId, status: "active" }, - data: { - synchronizationGeneration: { increment: 1 }, - synchronizationStatus: "dirty", - dirtyAt: now, - }, - }) + if (dirtyIds.size > 0) { + await transaction.gitHubRepositorySource.updateMany({ + where: { githubRepositoryId: { in: [...dirtyIds] }, status: "active" }, + data: { + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: now, + }, + }) + } else if (removedIds.size === 0) { + await transaction.gitHubRepositorySource.updateMany({ + where: { installationId: integration.installationId, status: "active" }, + data: { + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: now, + }, + }) + } } await saveSecurityAudit(transaction, { event: "github.webhook.process", diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 96d00549..ccd34625 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -8,6 +8,7 @@ import { getExternalIdentity } from "#server/repositories/external-identities/ge 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 { hasFreshGitHubAdmission } from "#server/repositories/github-synchronized-access/has-fresh-admission" import { advanceSessionGeneration } from "#server/repositories/identity-store-state/advance-session-generation" import { completeBootstrap } from "#server/repositories/identity-store-state/complete-bootstrap" import { getBootstrapCompletedAt } from "#server/repositories/identity-store-state/get-bootstrap-completed-at" @@ -49,6 +50,7 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident advanceSessionGeneration: advanceSessionGeneration.bind(null, database), completeBootstrap: completeBootstrap.bind(null, database), saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), + hasFreshGitHubAdmission: hasFreshGitHubAdmission.bind(null, database), user: getUser.bind(null, database), createUser: createUser.bind(null, database), updateUser: updateUser.bind(null, database), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 952d0d8c..9f38b1b7 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -49,6 +49,7 @@ export interface IdentityRecords { advanceSessionGeneration(): Promise completeBootstrap(completedAt: Date): Promise saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise + hasFreshGitHubAdmission(userId: string, now: Date): Promise user(id: string): Promise createUser(input: UserInput): Promise updateUser(id: string, input: UserInput): Promise diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index d4659854..e8bbf6ce 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -2030,6 +2030,79 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ]) }) + it("gets a workspace through prototype or baseline access while an explicit workspace deny wins", async () => { + const baselineUsers = new Set() + const baselineAccess = vi + .fn() + .mockImplementation((_database, userId) => + Promise.resolve( + baselineUsers.has(userId) + ? { + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + } + : null, + ), + ) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + const prototypeOnly = await createActor(store, { + subject: "workspace-prototype-only", + token: "workspace-prototype-only-token", + }) + const synchronized = await createActor(store, { + subject: "workspace-synchronized", + token: "workspace-synchronized-token", + }) + const denied = await createActor(store, { + subject: "workspace-denied", + token: "workspace-denied-token", + }) + baselineUsers.add(synchronized.user.id) + baselineUsers.add(denied.user.id) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: prototypeOnly.user.id, + grants: { + create: { + userId: denied.user.id, + effect: "deny", + }, + }, + prototypes: { + create: { + name: "Prototype", + createdByUserId: prototypeOnly.user.id, + grants: { + create: { + userId: prototypeOnly.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }, + }, + }) + + await expect( + service.getWorkspace("workspace-prototype-only-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.getWorkspace("workspace-synchronized-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.getWorkspace("workspace-denied-token", workspace.id), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + }) + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 815f4234..7d8521d8 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -614,7 +614,18 @@ export function createPrototypeAuthorizationService({ async getWorkspace(sessionToken, workspaceId) { const actor = await currentSession(store, sessionToken) const { workspace, decision } = await workspaceDecision(database, actor.userId, workspaceId) - requireDecision(decision, "viewer") + if ( + !decision.allowed && + !(await hasAnyPrototypeAccess({ + baseline, + database, + userId: actor.userId, + workspaceId, + workspaceGrant: workspace.grants[0] ?? null, + })) + ) { + requireDecision(decision, "viewer") + } return { id: workspace.id, createdByUserId: workspace.createdByUserId, diff --git a/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts b/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts new file mode 100644 index 00000000..122fc2c5 --- /dev/null +++ b/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts @@ -0,0 +1,27 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +const policyId = "00000000-0000-0000-0000-000000000001" + +export async function hasFreshGitHubAdmission( + database: Prisma.TransactionClient, + userId: string, + now: Date, +): Promise { + const policy = await database.gitHubAuthorizationPolicy.findUnique({ + where: { id: policyId }, + select: { lastKnownGoodSeconds: true }, + }) + const cutoff = new Date(now.getTime() - (policy?.lastKnownGoodSeconds ?? 86_400) * 1_000) + return ( + (await database.gitHubSynchronizedAccess.findFirst({ + where: { + userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + select: { id: true }, + })) !== null + ) +} From 4248502f7baa990bd53175085fdde3f7a6f72f62 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 16:47:23 -0700 Subject: [PATCH 05/12] fix(hub): invalidate disproved GitHub grants Order live admission confirmations, revoke cached access on repository loss, preserve invitation authority during source outages, and harden webhook and installation lifecycle handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 6 +- hub/docs/testing-and-operations.md | 2 +- .../migration.sql | 4 +- .../create-service/index.test.ts | 22 +- .../create-service/index.db.test.ts | 388 +++++++++++++++++- .../create-service/index.ts | 149 +++++-- .../create-service/index.ts | 2 +- 7 files changed, 531 insertions(+), 42 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index 79fd44b3..a4f1cc73 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -31,13 +31,13 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Admission confirmations carry that same generation and cannot overwrite a newer reconciliation result. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Each live admission permission request also claims its generation before calling GitHub, so an older response cannot overwrite a newer positive or negative confirmation. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation or admission attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. ## Staleness and outages -A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 suppresses that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. +A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 removes that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index f7951c0c..f2a5515e 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -59,7 +59,7 @@ A dedicated migration case applies the foundation migration, seeds retained life Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence, immutable-ID rename resolution, repository and installation removal, positive and negative permission confirmation, identity mismatch, built-in role-name mapping, provider and credential outage, App/installation-aware cache fallback, dirty and deletion webhooks, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, stale per-user and overlapping reconciliation, unlink during reconciliation, manual suspension and administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation, identity mismatch, built-in role-name mapping, provider and credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and deletion events, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql index b5941705..06cb6f3c 100644 --- a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -54,7 +54,9 @@ CREATE TABLE "github_repository_sources" ( "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "github_repository_sources_pkey" PRIMARY KEY ("id"), - CONSTRAINT "github_repository_sources_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_repository_sources_status" CHECK ( + "status" IN ('active', 'removed', 'suspended') + ), CONSTRAINT "github_repository_sources_synchronization_generation" CHECK ( "synchronization_generation" >= 0 ), diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index f5468717..bd672116 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -5,6 +5,7 @@ import { createAuthenticationService } from "#server/domains/authentication/crea import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" import type { GitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client/types" +import { AuthenticationError } from "#server/utils/authentication-error" import { digestSecret } from "#server/utils/digest-secret" const now = new Date("2026-10-27T18:00:00.000Z") @@ -353,7 +354,7 @@ describe("createAuthenticationService()", () => { expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) }) - it("uses an invitation before a qualifying synchronized baseline for a new identity", async () => { + it("uses an invitation for a new identity when synchronized admission is unavailable", async () => { const invitation = { id: "invitation", createdByUserId: "administrator", @@ -398,14 +399,15 @@ describe("createAuthenticationService()", () => { const options = dependencies(identityRecords, { admissionPolicy: { name: "github-synchronized", - admit: vi.fn().mockResolvedValue({ - repositorySourceId: "00000000-0000-0000-0000-000000000001", - sourceGeneration: 3, - rawPermission: "read", - admissionEligible: true, - prototypeRole: "viewer", - confirmedAt: now, - }), + admit: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ), + ), saveBaseline, }, }) @@ -433,7 +435,7 @@ describe("createAuthenticationService()", () => { "membership", now, ) - expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) + expect(saveBaseline).not.toHaveBeenCalled() }) it("admits an active Hub member without consulting legacy repository policy", async () => { diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 7a20a839..989036a1 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -1,4 +1,5 @@ import { createHmac, generateKeyPairSync } from "node:crypto" +import { readFileSync } from "node:fs" import { describe, expect, it, onTestFinished, vi } from "vitest" import { createAdministrationService } from "#server/domains/administration/create-service" @@ -995,7 +996,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }), ).resolves.toMatchObject({ repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, + sourceGeneration: source.synchronizationGeneration + 1, rawPermission, prototypeRole, admissionEligible: true, @@ -1008,9 +1009,18 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, ) - it("treats an immutable repository lookup 404 as definitive denial instead of cached access", async () => { + it("invalidates a cached grant and session after an immutable repository 404", async () => { const { prisma, service, store } = await database() - const actor = await createUser(store, { subject: "42" }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + await createUser(store, { + subject: "43", + role: "administrator", + token: "administrator-token", + }) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -1049,6 +1059,71 @@ describe("createGitHubAuthorizationService() database behavior", () => { avatarUrl: null, }), ).resolves.toBeNull() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", admissionEligible: true }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ), + ) + await service.linkRepository("administrator-token", { + githubRepositoryId: source.githubRepositoryId, + admissionEnabled: true, + }) + + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 503 }), + ), + ), + ) + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) }) it("records source configuration mismatch as failed without refreshing confirmation", async () => { @@ -1187,6 +1262,44 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it.each([ + { + name: "an inaccessible repository", + response: new Response(null, { status: 404 }), + expected: { status: 422, code: "repository_source_inaccessible" }, + }, + { + name: "a provider authorization failure", + response: new Response(null, { status: 401 }), + expected: { status: 503, code: "source_integration_unavailable" }, + }, + { + name: "a malformed provider response", + response: new Response(JSON.stringify({ id: 100 }), { status: 200 }), + expected: { status: 503, code: "source_integration_unavailable" }, + }, + ])("returns a stable linking error for $name", async ({ response, expected }) => { + const { service, store } = await database() + await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce(response), + ) + + await expect( + service.linkRepository("administrator-token", { githubRepositoryId: "100" }), + ).rejects.toMatchObject(expected) + }) + it("revokes synchronized access immediately for a deleted repository but preserves compatibility membership", async () => { const { prisma, service, store } = await database() const synchronized = await createUser(store, { @@ -1364,6 +1477,135 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("keeps a newer denied sign-in authoritative when an older grant responds later", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock(async (records) => { + for (const suffix of ["older", "newer"]) { + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret(`${suffix}-state`), + browserDigest: digestSecret(`${suffix}-browser`), + pkceVerifier: `${suffix}-verifier`, + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }) + } + }) + const olderPermission = deferred() + const olderPermissionRequested = deferred() + let permissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + }), + { status: 200 }, + ), + ) + } + permissionRequests += 1 + if (permissionRequests === 1) { + olderPermissionRequested.resolve() + return olderPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const olderSignIn = authentication.complete("older-code", "older-state", "older-browser") + await olderPermissionRequested.promise + await expect( + authentication.complete("newer-code", "newer-state", "newer-browser"), + ).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + olderPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + await expect(olderSignIn).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + + expect(permissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + it("does not overwrite an administrator suspension while reconciliation is waiting", async () => { const { prisma, service, store } = await database() await createUser(store, { @@ -1615,6 +1857,146 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("rejects a non-ASCII signature with equal character length as unauthorized", async () => { + const { service } = await database() + const input = webhook({}, "ping", "non-ascii-signature") + const signature = `${input.signature.slice(0, -1)}é` + expect(signature).toHaveLength(input.signature.length) + + await expect(service.processWebhook({ ...input, signature })).rejects.toMatchObject({ + status: 401, + code: "invalid_github_webhook", + }) + }) + + it("ignores a signed GitHub ping without an installation payload", async () => { + const { prisma, service } = await database({ + GITHUB_APP_ID: "", + GITHUB_INSTALLATION_ID: "", + GITHUB_PRIVATE_KEY: "", + }) + + await expect( + service.processWebhook( + webhook({ zen: "Keep it logically awesome.", hook_id: 1 }, "ping", "github-ping"), + ), + ).resolves.toBe("ignored") + expect(await prisma.gitHubWebhookDelivery.count()).toBe(0) + }) + + it("suspends linked sources without losing policy and resumes them dirty without baselines", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + }, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + + await expect( + service.processWebhook( + webhook( + { action: "suspend", installation: { id: 7 } }, + "installation", + "installation-suspend", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "suspended", + admissionEnabled: true, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + + await expect( + service.processWebhook( + webhook( + { action: "unsuspend", installation: { id: 7 } }, + "installation", + "installation-unsuspend", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "active", + admissionEnabled: true, + synchronizationStatus: "dirty", + synchronizationError: null, + removedAt: null, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: actor.user.id } })).toBe(0) + }) + + it("keeps the synchronized authorization migration constraints and compatibility backfill", () => { + const migration = readFileSync( + new URL( + "../../../../schema/migrations/20261027230000_github_synchronized_authorization/migration.sql", + import.meta.url, + ), + "utf8", + ) + + expect(migration).toMatch( + /CONSTRAINT "github_repository_sources_status" CHECK \(\s*"status" IN \('active', 'removed', 'suspended'\)\s*\)/, + ) + expect(migration).toContain("'legacy-synchronized'") + expect(migration).toMatch( + /UPDATE "installation_memberships"\s+SET "source" = 'legacy-synchronized'\s+WHERE "source" = 'synchronized';/, + ) + }) + it("rolls back webhook mutations when the required revocation audit fails", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 221e10ec..a69c0e87 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -227,7 +227,10 @@ async function saveBaseline( rawPermission: baseline.rawPermission, mappedPrototypeRole: baseline.prototypeRole, admissionEligible: baseline.admissionEligible, + status: baseline.admissionEligible ? "active" : "removed", + synchronizationStatus: baseline.admissionEligible ? "current" : "removed", lastConfirmedAt: baseline.confirmedAt, + removedAt: baseline.admissionEligible ? null : baseline.confirmedAt, }, update: { rawPermission: baseline.rawPermission, @@ -350,6 +353,55 @@ async function removeRepositorySource( await enforceSynchronizedMemberships(database, removedAt) } +async function suspendInstallationSources( + database: Prisma.TransactionClient, + installationId: string, + suspendedAt: Date, +): Promise { + const sources = await database.gitHubRepositorySource.findMany({ + where: { installationId, status: "active" }, + select: { id: true }, + }) + for (const { id } of sources) await lockRepository(database, id) + await database.gitHubRepositorySource.updateMany({ + where: { installationId, status: "active" }, + data: { + status: "suspended", + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "removed", + removedAt: suspendedAt, + }, + }) + await database.gitHubSynchronizedAccess.updateMany({ + where: { repositorySource: { installationId } }, + data: { status: "removed", synchronizationStatus: "removed", removedAt: suspendedAt }, + }) + await enforceSynchronizedMemberships(database, suspendedAt) +} + +async function resumeInstallationSources( + database: Prisma.TransactionClient, + installationId: string, + resumedAt: Date, +): Promise { + const sources = await database.gitHubRepositorySource.findMany({ + where: { installationId, status: "suspended" }, + select: { id: true }, + }) + for (const { id } of sources) await lockRepository(database, id) + await database.gitHubRepositorySource.updateMany({ + where: { installationId, status: "suspended" }, + data: { + status: "active", + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + synchronizationError: null, + removedAt: null, + dirtyAt: resumedAt, + }, + }) +} + export function createGitHubAuthorizationService({ database, environment, @@ -369,6 +421,20 @@ export function createGitHubAuthorizationService({ return credentials(store, createGitHubIntegrationCredentialProvider(environment)) } + async function claimAdmissionGeneration(repositorySourceId: string) { + return store.lock(async (_records, transaction) => { + await lockRepository(transaction, repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active" || !source.admissionEnabled) return null + return transaction.gitHubRepositorySource.update({ + where: { id: repositorySourceId }, + data: { synchronizationGeneration: { increment: 1 } }, + }) + }) + } + async function reconcile(repositorySourceId: string, actorSessionToken?: string) { const source = await database.$transaction(async (transaction) => { await lockRepository(transaction, repositorySourceId) @@ -645,9 +711,25 @@ export function createGitHubAuthorizationService({ await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), ) } catch (error) { - if (error instanceof GitHubApiError && error.status === 404) continue + if (error instanceof GitHubApiError && error.status === 404) { + await store.lock(async (_records, transaction) => { + await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + current?.status === "active" && + current.synchronizationGeneration === source.synchronizationGeneration + ) { + await removeRepositorySource(transaction, source.id, new Date()) + } + }) + continue + } throw error } + const claimedSource = await claimAdmissionGeneration(source.id) + if (!claimedSource) continue const rawPermission = await permissionFor( token, profile.username, @@ -658,7 +740,7 @@ export function createGitHubAuthorizationService({ if (mapped.admissionEligible) { return { repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, + sourceGeneration: claimedSource.synchronizationGeneration, rawPermission, ...mapped, confirmedAt: new Date(), @@ -668,7 +750,7 @@ export function createGitHubAuthorizationService({ await store.lock((_records, transaction) => saveBaseline(transaction, identity.userId, { repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, + sourceGeneration: claimedSource.synchronizationGeneration, rawPermission, ...mapped, confirmedAt: new Date(), @@ -790,11 +872,29 @@ export function createGitHubAuthorizationService({ ) } await currentAdministrator(store, sessionToken) - const integration = await sourceCredentials() - const token = await createInstallationToken(integration) - const repository = repositorySchema.parse( - await requestGitHubApi(`/repositories/${parsed.data.githubRepositoryId}`, token), - ) + let integration: GitHubCredentials + let repository: z.infer + try { + integration = await sourceCredentials() + const token = await createInstallationToken(integration) + repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${parsed.data.githubRepositoryId}`, token), + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) { + throw new AuthenticationError( + 422, + "repository_source_inaccessible", + "The GitHub repository is not accessible to this installation.", + ) + } + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } return store.lock(async (_records, transaction) => { const actor = await currentAdministrator(store, sessionToken, transaction) const linked = await transaction.gitHubRepositorySource.upsert({ @@ -955,9 +1055,11 @@ export function createGitHubAuthorizationService({ ) } const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}` + const signatureBuffer = Buffer.from(signature) + const expectedBuffer = Buffer.from(expected) const valid = - signature.length === expected.length && - timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) + signatureBuffer.length === expectedBuffer.length && + timingSafeEqual(signatureBuffer, expectedBuffer) if (!valid) { throw new AuthenticationError( 401, @@ -972,6 +1074,15 @@ export function createGitHubAuthorizationService({ "The GitHub webhook is invalid.", ) } + const relevant = new Set([ + "installation", + "installation_repositories", + "repository", + "member", + "membership", + "team_add", + ]) + if (!relevant.has(event)) return "ignored" let decoded: unknown try { decoded = JSON.parse(body.toString("utf8")) @@ -992,15 +1103,6 @@ export function createGitHubAuthorizationService({ } const integration = await sourceCredentials() if (String(payload.data.installation.id) !== integration.installationId) return "ignored" - const relevant = new Set([ - "installation", - "installation_repositories", - "repository", - "member", - "membership", - "team_add", - ]) - if (!relevant.has(event)) return "ignored" try { return await store.lock(async (_records, transaction) => { const repositoryId = payload.data.repository?.id @@ -1019,10 +1121,7 @@ export function createGitHubAuthorizationService({ }, }) const now = new Date() - if ( - event === "installation" && - ["deleted", "suspend"].includes(payload.data.action ?? "") - ) { + if (event === "installation" && payload.data.action === "deleted") { const sources = await transaction.gitHubRepositorySource.findMany({ where: { installationId: integration.installationId, status: "active" }, select: { id: true }, @@ -1030,6 +1129,10 @@ export function createGitHubAuthorizationService({ for (const source of sources) { await removeRepositorySource(transaction, source.id, now) } + } else if (event === "installation" && payload.data.action === "suspend") { + await suspendInstallationSources(transaction, integration.installationId, now) + } else if (event === "installation" && payload.data.action === "unsuspend") { + await resumeInstallationSources(transaction, integration.installationId, now) } else { const dirtyIds = new Set() const removedIds = new Set() diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 7d8521d8..f35e34eb 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -393,6 +393,7 @@ async function readAccessibleWorkspacePage({ while (items.length <= limit) { const candidates = await database.workspace.findMany({ where: { + ...(scanCursor ? { id: { gt: scanCursor } } : {}), OR: [ { grants: { some: { userId } } }, { prototypes: { some: { grants: { some: { userId } } } } }, @@ -401,7 +402,6 @@ async function readAccessibleWorkspacePage({ }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, - ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), take: 100, }) if (candidates.length === 0) break From d7afa77501ae1a5c920ea32d7932873253f37d35 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 20:52:12 -0700 Subject: [PATCH 06/12] fix(hub): isolate GitHub admission attempts Require collaborator membership for public sources, separate per-user admission ordering from reconciliation generations, preserve outage fallback, and cover policy mutation APIs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/database.md | 1 + hub/docs/github-integration.md | 4 +- hub/schema/github-admission-attempt.prisma | 19 + hub/schema/github-repository-source.prisma | 2 + .../migration.sql | 19 + .../create-service/index.db.test.ts | 362 +++++++++++++++--- .../create-service/index.ts | 76 +++- .../create-service/types.ts | 3 + .../create-service/index.db.test.ts | 123 ++++++ .../check-repository-admission/constants.ts | 1 + .../github/request-github-api/index.ts | 1 + hub/server/routes/product/index.test.ts | 38 ++ 12 files changed, 584 insertions(+), 65 deletions(-) create mode 100644 hub/schema/github-admission-attempt.prisma diff --git a/hub/docs/database.md b/hub/docs/database.md index 5aeb5897..9044c181 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -53,6 +53,7 @@ Workspace and prototype access use separate grant tables so each user has at mos GitHub synchronization uses separate records: - `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, synchronization generation, and timestamps do not define identity. The generation is a compare-and-set boundary for rejecting stale in-flight reconciliation results. +- `GitHubAdmissionAttempt` stores a per-repository, per-GitHub-subject generation. Live sign-ins use it to order one user's permission confirmations without invalidating reconciliation or another user's sign-in. - `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. - `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. - `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index a4f1cc73..da36675c 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -4,7 +4,7 @@ GitHub-synchronized authorization uses repository permissions as an opt-in basel ## Source identity and credentials -A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, active or removed state, admission switch, synchronization status, and timestamps. Admission resolves the current owner and name from that immutable ID before checking a user permission, so a renamed repository cannot be confused with a replacement at its old name. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. +A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, visibility, active, suspended, or removed state, admission switch, synchronization status, and timestamps. Admission resolves current metadata from that immutable ID before checking a user permission, so a renamed repository cannot be confused with a replacement at its old name. Public and internal sources additionally require GitHub to confirm that the user is an actual collaborator before their permission can supply admission or a prototype baseline. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. All permission checks use an installation token created from the configured GitHub source-integration credentials. GitHub user authorization proves identity only. Hub does not retain or reuse the user's transient token for synchronization. @@ -31,7 +31,7 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Each live admission permission request also claims its generation before calling GitHub, so an older response cannot overwrite a newer positive or negative confirmation. After GitHub replies, Hub locks and re-reads the source and applies results only when it is still active and the claimed generation is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation or admission attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Live admission permission requests use a separate generation per repository and immutable GitHub subject, so an older response cannot overwrite that user's newer positive or negative confirmation without invalidating reconciliation or another user's sign-in. After GitHub replies, Hub locks and re-reads the relevant generation and applies results only when it is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation or same-user admission attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. `POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. diff --git a/hub/schema/github-admission-attempt.prisma b/hub/schema/github-admission-attempt.prisma new file mode 100644 index 00000000..0c2603d0 --- /dev/null +++ b/hub/schema/github-admission-attempt.prisma @@ -0,0 +1,19 @@ +model GitHubAdmissionAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String @map("repository_source_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource @relation(fields: [repositorySourceId], references: [id], onDelete: Cascade) + + // Attribute columns + subject String + generation Int @default(0) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([repositorySourceId, subject]) + @@map("github_admission_attempts") +} diff --git a/hub/schema/github-repository-source.prisma b/hub/schema/github-repository-source.prisma index 6d64f4d0..7113a058 100644 --- a/hub/schema/github-repository-source.prisma +++ b/hub/schema/github-repository-source.prisma @@ -6,6 +6,7 @@ model GitHubRepositorySource { // Relations prototypes Prototype[] synchronizedAccess GitHubSynchronizedAccess[] + admissionAttempts GitHubAdmissionAttempt[] webhookDeliveries GitHubWebhookDelivery[] auditTargets SecurityAudit[] @@ -15,6 +16,7 @@ model GitHubRepositorySource { owner String name String defaultBranch String @map("default_branch") + visibility String @default("unknown") status String @default("active") admissionEnabled Boolean @default(false) @map("admission_enabled") synchronizationGeneration Int @default(0) @map("synchronization_generation") diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql index 06cb6f3c..47d1f6f1 100644 --- a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -41,6 +41,7 @@ CREATE TABLE "github_repository_sources" ( "owner" TEXT NOT NULL, "name" TEXT NOT NULL, "default_branch" TEXT NOT NULL, + "visibility" TEXT NOT NULL DEFAULT 'unknown', "status" TEXT NOT NULL DEFAULT 'active', "admission_enabled" BOOLEAN NOT NULL DEFAULT false, "synchronization_generation" INTEGER NOT NULL DEFAULT 0, @@ -62,9 +63,25 @@ CREATE TABLE "github_repository_sources" ( ), CONSTRAINT "github_repository_sources_synchronization_status" CHECK ( "synchronization_status" IN ('dirty', 'current', 'failed', 'removed') + ), + CONSTRAINT "github_repository_sources_visibility" CHECK ( + "visibility" IN ('internal', 'private', 'public', 'unknown') ) ); +-- CreateTable +CREATE TABLE "github_admission_attempts" ( + "id" UUID NOT NULL, + "repository_source_id" UUID NOT NULL, + "subject" TEXT NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_admission_attempts_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_admission_attempts_generation" CHECK ("generation" >= 0) +); + -- CreateTable CREATE TABLE "github_synchronized_access" ( "id" UUID NOT NULL, @@ -118,6 +135,7 @@ ADD COLUMN "github_repository_source_id" UUID; -- CreateIndex CREATE UNIQUE INDEX "github_repository_sources_github_repository_id_key" ON "github_repository_sources"("github_repository_id"); +CREATE UNIQUE INDEX "github_admission_attempts_repository_source_id_subject_key" ON "github_admission_attempts"("repository_source_id", "subject"); CREATE INDEX "github_repository_sources_installation_id_status_idx" ON "github_repository_sources"("installation_id", "status"); CREATE UNIQUE INDEX "github_synchronized_access_repository_source_id_user_id_key" ON "github_synchronized_access"("repository_source_id", "user_id"); CREATE INDEX "github_synchronized_access_user_id_status_idx" ON "github_synchronized_access"("user_id", "status"); @@ -128,6 +146,7 @@ CREATE INDEX "security_audits_github_repository_source_id_idx" ON "security_audi -- AddForeignKey ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "github_admission_attempts" ADD CONSTRAINT "github_admission_attempts_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "github_webhook_deliveries" ADD CONSTRAINT "github_webhook_deliveries_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_source_repository_id_fkey" FOREIGN KEY ("source_repository_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 989036a1..910fe929 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -5,9 +5,15 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createAdministrationService } from "#server/domains/administration/create-service" import { createAuthenticationService } from "#server/domains/authentication/create-service" import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" +import type { + GitHubAdmissionBaseline, + GitHubAuthorizationService, +} from "#server/domains/github-authorization/create-service/types" import { createIdentityStore } from "#server/domains/identity/create-store" import { digestSecret } from "#server/utils/digest-secret" +import type { PrismaClient } from "../../../generated/prisma/client.ts" + type IdentityStore = ReturnType function environment() { @@ -126,6 +132,31 @@ function requestUrl(input: string | URL | Request): string { return typeof input === "string" ? input : input instanceof URL ? input.href : input.url } +async function saveAdmissionFixture( + prisma: PrismaClient, + service: GitHubAuthorizationService, + userId: string, + subject: string, + baseline: Omit, +) { + const attempt = await prisma.gitHubAdmissionAttempt.upsert({ + where: { + repositorySourceId_subject: { + repositorySourceId: baseline.repositorySourceId, + subject, + }, + }, + create: { repositorySourceId: baseline.repositorySourceId, subject, generation: 1 }, + update: { generation: { increment: 1 } }, + }) + await service.saveAdmissionBaseline(prisma, userId, { + ...baseline, + subject, + admissionGeneration: attempt.generation, + persist: true, + }) +} + describe("createGitHubAuthorizationService() database behavior", () => { it("uses only a fresh baseline for the linked repository and keeps repositories isolated", async () => { const { prisma, service, store } = await database() @@ -193,7 +224,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }), ]) const confirmedAt = new Date("2026-10-27T20:00:00.000Z") - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "write", @@ -267,7 +298,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -355,6 +386,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "new-name", owner: { login: "new-owner" }, default_branch: "trunk", + visibility: "private", }, { user: { id: 42 }, permission: "none" }, ] @@ -425,7 +457,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -477,6 +509,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "resurrected-name", owner: { login: "resurrected-owner" }, default_branch: "trunk", + visibility: "private", }), { status: 200 }, ), @@ -560,6 +593,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -606,7 +640,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }), ]) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: sourceA.id, sourceGeneration: sourceA.synchronizationGeneration, rawPermission: "write", @@ -632,6 +666,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository-a", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -709,7 +744,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) - it("uses a fresh cached grant only when that same repository's live check fails", async () => { + it("uses a fresh cached grant after metadata succeeds without persisting the fallback", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) const source = await prisma.gitHubRepositorySource.create({ @@ -724,7 +759,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }) const confirmedAt = new Date() - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -734,30 +769,66 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) vi.stubGlobal( "fetch", - vi.fn((input: string | URL | Request) => - Promise.resolve( - requestUrl(input).endsWith("/access_tokens") - ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) - : new Response(null, { status: 503 }), - ), - ), + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), ) - await expect( - service.evaluateAdmission({ - subject: actor.identity.subject, - username: actor.identity.username!, - displayName: actor.user.displayName, - avatarUrl: null, - }), - ).resolves.toEqual({ + const admission = await service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + expect(admission).toEqual({ repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 0, + persist: false, rawPermission: "read", admissionEligible: true, prototypeRole: "viewer", confirmedAt, }) + await service.saveAdmissionBaseline(prisma, actor.user.id, admission!) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + lastConfirmedAt: confirmedAt, + }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ synchronizationGeneration: source.synchronizationGeneration }) }) it.each([ @@ -782,7 +853,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -864,7 +935,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, }) const confirmedAt = new Date("2026-10-28T18:00:00.000Z") - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "write", @@ -881,6 +952,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 1, + persist: true, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -894,6 +968,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { service.saveAdmissionBaseline(prisma, actor.user.id, { repositorySourceId: source.id, sourceGeneration: currentSource.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 1, + persist: true, rawPermission: "read", prototypeRole: "viewer", admissionEligible: true, @@ -964,6 +1041,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "renamed-repository", owner: { login: "renamed-owner" }, default_branch: "trunk", + visibility: "private", }), { status: 200 }, ), @@ -996,7 +1074,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { }), ).resolves.toMatchObject({ repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration + 1, + sourceGeneration: source.synchronizationGeneration, + subject: "42", + admissionGeneration: 1, + persist: true, rawPermission, prototypeRole, admissionEligible: true, @@ -1009,6 +1090,95 @@ describe("createGitHubAuthorizationService() database behavior", () => { }, ) + it.each([ + { visibility: "public", collaboratorStatus: 204, admitted: true }, + { visibility: "public", collaboratorStatus: 404, admitted: false }, + { visibility: "internal", collaboratorStatus: 204, admitted: true }, + { visibility: "internal", collaboratorStatus: 404, admitted: false }, + ] as const)( + "$visibility repository admission is $admitted when collaborator membership returns $collaboratorStatus", + async ({ visibility, collaboratorStatus, admitted }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + visibility, + admissionEnabled: true, + }, + }) + const request = vi.fn((input: string | URL | Request): Promise => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility, + }), + { status: 200 }, + ), + ) + } + if (url.endsWith("/repos/owner/repository/collaborators/octocat")) { + return Promise.resolve(new Response(null, { status: collaboratorStatus })) + } + if (url.endsWith("/repos/owner/repository/collaborators/octocat/permission")) { + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { + status: 200, + }), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + const admission = await service.evaluateAdmission({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + if (admitted) { + expect(admission).toMatchObject({ + repositorySourceId: source.id, + subject: "42", + admissionGeneration: 1, + persist: true, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + }) + } else { + expect(admission).toBeNull() + } + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/owner/repository/collaborators/octocat", + ...(admitted + ? ["https://api.github.com/repos/owner/repository/collaborators/octocat/permission"] + : []), + ]) + }, + ) + it("invalidates a cached grant and session after an immutable repository 404", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { @@ -1032,7 +1202,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "write", @@ -1093,6 +1263,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -1245,6 +1416,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "new-name", owner: { login: "new-owner" }, default_branch: "trunk", + visibility: "private", }), { status: 200 }, ), @@ -1323,14 +1495,20 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, synchronized.user.id, { - repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, - rawPermission: "read", - prototypeRole: "viewer", - admissionEligible: true, - confirmedAt: new Date(), - }) + await saveAdmissionFixture( + prisma, + service, + synchronized.user.id, + synchronized.identity.subject, + { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }, + ) await expect( service.processWebhook( @@ -1343,6 +1521,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }, }, "repository", @@ -1406,6 +1585,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -1531,6 +1711,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -1629,7 +1810,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, target.user.id, { + await saveAdmissionFixture(prisma, service, target.user.id, target.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -1657,6 +1838,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), @@ -1714,7 +1896,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, lost.user.id, { + await saveAdmissionFixture(prisma, service, lost.user.id, lost.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -1724,6 +1906,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) const targetPermission = deferred() const targetPermissionRequested = deferred() + let targetPermissionRequests = 0 vi.stubGlobal( "fetch", vi.fn((input: string | URL | Request) => { @@ -1742,14 +1925,23 @@ describe("createGitHubAuthorizationService() database behavior", () => { name: "repository", owner: { login: "owner" }, default_branch: "main", + visibility: "private", }), { status: 200 }, ), ) } if (url.includes("user-42")) { - targetPermissionRequested.resolve() - return targetPermission.promise + targetPermissionRequests += 1 + if (targetPermissionRequests === 1) { + targetPermissionRequested.resolve() + return targetPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { + status: 200, + }), + ) } return Promise.resolve( new Response(JSON.stringify({ user: { id: 43 }, permission: "none" }), { status: 200 }), @@ -1759,9 +1951,6 @@ describe("createGitHubAuthorizationService() database behavior", () => { const reconciliation = service.reconcileDue() await targetPermissionRequested.promise - const currentSource = await prisma.gitHubRepositorySource.findUniqueOrThrow({ - where: { id: source.id }, - }) const now = new Date() const provider = await store.read.currentAuthenticationProviderRevision("github") await store.lock((records) => @@ -1776,14 +1965,6 @@ describe("createGitHubAuthorizationService() database behavior", () => { expiresAt: new Date(now.getTime() + 60_000), }), ) - const freshBaseline = { - repositorySourceId: source.id, - sourceGeneration: currentSource.synchronizationGeneration, - rawPermission: "write" as const, - prototypeRole: "editor" as const, - admissionEligible: true, - confirmedAt: new Date(now.getTime() + 60_000), - } const authentication = createAuthenticationService({ store, github: { @@ -1804,11 +1985,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { providerRevisionId: provider!.id, admissionPolicy: { name: "github-synchronized", - admit: vi.fn().mockResolvedValue(freshBaseline), + admit: (profile) => service.evaluateAdmission(profile), saveBaseline: (database, userId, baseline) => service.saveAdmissionBaseline(database, userId, baseline), }, - now: () => now, }) await expect(authentication.complete("code", "state", "browser-token")).resolves.toMatchObject({ @@ -1819,6 +1999,12 @@ describe("createGitHubAuthorizationService() database behavior", () => { ) await reconciliation + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "current", + }) expect( await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), ).toMatchObject({ source: "synchronized", status: "active" }) @@ -1834,7 +2020,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ rawPermission: "write", admissionEligible: true, - lastConfirmedAt: freshBaseline.confirmedAt, + status: "active", }) expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(1) expect( @@ -1903,7 +2089,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { synchronizationStatus: "current", }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", @@ -1979,6 +2165,76 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.count({ where: { userId: actor.user.id } })).toBe(0) }) + it("unlinks a suspended source and clears its admission policy and baselines", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await service.processWebhook( + webhook( + { action: "suspend", installation: { id: 7 } }, + "installation", + "installation-suspend-before-unlink", + ), + ) + + await expect( + service.unlinkRepository("administrator-token", source.id), + ).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + status: "removed", + synchronizationStatus: "removed", + }) + expect( + await prisma.securityAudit.findFirst({ + where: { event: "github.repository.unlink", githubRepositorySourceId: source.id }, + }), + ).toMatchObject({ actorUserId: administrator.user.id }) + }) + it("keeps the synchronized authorization migration constraints and compatibility backfill", () => { const migration = readFileSync( new URL( @@ -2015,7 +2271,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - await service.saveAdmissionBaseline(prisma, actor.user.id, { + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, rawPermission: "read", diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index a69c0e87..a1f1c2e5 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -123,8 +123,20 @@ async function permissionFor( username: string, subject: string, fullName: string, + visibility: string, ): Promise { try { + if (visibility !== "private") { + try { + await requestGitHubApi( + `/repos/${fullName}/collaborators/${encodeURIComponent(username)}`, + token, + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return "none" + throw error + } + } const response = collaboratorPermissionSchema.parse( await requestGitHubApi( `/repos/${fullName}/collaborators/${encodeURIComponent(username)}/permission`, @@ -175,7 +187,9 @@ async function saveBaseline( baseline: GitHubAdmissionBaseline, requireAdmissionEnabled = false, skipOlderConfirmation = false, + requireAdmissionAttempt = true, ): Promise<"saved" | "superseded"> { + if (!baseline.persist) return "saved" await lockRepository(database, baseline.repositorySourceId) const source = await database.gitHubRepositorySource.findUnique({ where: { id: baseline.repositorySourceId }, @@ -197,6 +211,24 @@ async function saveBaseline( "GitHub authorization changed. Start again.", ) } + if (requireAdmissionAttempt) { + const attempt = await database.gitHubAdmissionAttempt.findUnique({ + where: { + repositorySourceId_subject: { + repositorySourceId: baseline.repositorySourceId, + subject: baseline.subject, + }, + }, + select: { generation: true }, + }) + if (!attempt || attempt.generation !== baseline.admissionGeneration) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + } const existing = await database.gitHubSynchronizedAccess.findUnique({ where: { repositorySourceId_userId: { @@ -421,17 +453,19 @@ export function createGitHubAuthorizationService({ return credentials(store, createGitHubIntegrationCredentialProvider(environment)) } - async function claimAdmissionGeneration(repositorySourceId: string) { + async function claimAdmissionGeneration(repositorySourceId: string, subject: string) { return store.lock(async (_records, transaction) => { await lockRepository(transaction, repositorySourceId) const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) if (!source || source.status !== "active" || !source.admissionEnabled) return null - return transaction.gitHubRepositorySource.update({ - where: { id: repositorySourceId }, - data: { synchronizationGeneration: { increment: 1 } }, + const attempt = await transaction.gitHubAdmissionAttempt.upsert({ + where: { repositorySourceId_subject: { repositorySourceId, subject } }, + create: { repositorySourceId, subject, generation: 1 }, + update: { generation: { increment: 1 } }, }) + return { source, admissionGeneration: attempt.generation } }) } @@ -497,9 +531,11 @@ export function createGitHubAuthorizationService({ identity.username!, identity.subject, repository.full_name, + repository.visibility, ) return { userId: identity.userId, + subject: identity.subject, rawPermission, ...mapGitHubPermission(rawPermission), } @@ -525,6 +561,7 @@ export function createGitHubAuthorizationService({ owner: repository.owner.login, name: repository.name, defaultBranch: repository.default_branch, + visibility: repository.visibility, status: "active", removedAt: null, synchronizationStatus: "current", @@ -540,6 +577,9 @@ export function createGitHubAuthorizationService({ { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: result.subject, + admissionGeneration: 0, + persist: true, rawPermission: result.rawPermission, admissionEligible: result.admissionEligible, prototypeRole: result.prototypeRole, @@ -547,6 +587,7 @@ export function createGitHubAuthorizationService({ }, false, true, + false, ) } await enforceSynchronizedMemberships(transaction, confirmedAt) @@ -680,6 +721,9 @@ export function createGitHubAuthorizationService({ return { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: 0, + persist: false, rawPermission: baseline.rawPermission as GitHubRepositoryPermission, ...mapped, confirmedAt: baseline.lastConfirmedAt, @@ -728,19 +772,23 @@ export function createGitHubAuthorizationService({ } throw error } - const claimedSource = await claimAdmissionGeneration(source.id) - if (!claimedSource) continue + const claim = await claimAdmissionGeneration(source.id, profile.subject) + if (!claim) continue const rawPermission = await permissionFor( token, profile.username, profile.subject, repository.full_name, + repository.visibility, ) const mapped = mapGitHubPermission(rawPermission) if (mapped.admissionEligible) { return { repositorySourceId: source.id, - sourceGeneration: claimedSource.synchronizationGeneration, + sourceGeneration: claim.source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: claim.admissionGeneration, + persist: true, rawPermission, ...mapped, confirmedAt: new Date(), @@ -750,7 +798,10 @@ export function createGitHubAuthorizationService({ await store.lock((_records, transaction) => saveBaseline(transaction, identity.userId, { repositorySourceId: source.id, - sourceGeneration: claimedSource.synchronizationGeneration, + sourceGeneration: claim.source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: claim.admissionGeneration, + persist: true, rawPermission, ...mapped, confirmedAt: new Date(), @@ -766,6 +817,9 @@ export function createGitHubAuthorizationService({ return { repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: 0, + persist: false, rawPermission: baseline.rawPermission as GitHubRepositoryPermission, ...mapped, confirmedAt: baseline.lastConfirmedAt, @@ -906,6 +960,7 @@ export function createGitHubAuthorizationService({ owner: repository.owner.login, name: repository.name, defaultBranch: repository.default_branch, + visibility: repository.visibility, admissionEnabled: parsed.data.admissionEnabled ?? false, dirtyAt: new Date(), }, @@ -915,6 +970,7 @@ export function createGitHubAuthorizationService({ owner: repository.owner.login, name: repository.name, defaultBranch: repository.default_branch, + visibility: repository.visibility, status: "active", removedAt: null, ...(parsed.data.admissionEnabled === undefined @@ -952,7 +1008,7 @@ export function createGitHubAuthorizationService({ const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) - if (!source || source.status !== "active") { + if (!source || !["active", "suspended"].includes(source.status)) { throw new AuthenticationError( 404, "repository_source_not_found", @@ -992,7 +1048,7 @@ export function createGitHubAuthorizationService({ const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) - if (!source || source.status !== "active") { + if (!source || !["active", "suspended"].includes(source.status)) { throw new AuthenticationError( 404, "repository_source_not_found", diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts index faf87aad..66328656 100644 --- a/hub/server/domains/github-authorization/create-service/types.ts +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -5,6 +5,9 @@ import type { GitHubRepositoryPermission, MappedGitHubPermission } from "../map- export interface GitHubAdmissionBaseline extends MappedGitHubPermission { repositorySourceId: string sourceGeneration: number + subject: string + admissionGeneration: number + persist: boolean rawPermission: GitHubRepositoryPermission confirmedAt: Date } diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index e8bbf6ce..5e51ed03 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -226,6 +226,129 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(await prisma.securityAudit.count()).toBe(0) }) + it("enforces fresh prototype administration and saves authorization mode changes with audits", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "authorization-administrator", + token: "authorization-administrator-token", + role: "administrator", + }) + const workspace = (await service.createWorkspace("authorization-administrator-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype( + "authorization-administrator-token", + workspace.id, + { name: "Prototype" }, + )) as { id: string } + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + status: "suspended", + }, + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date() }, + }) + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).rejects.toMatchObject({ + status: 422, + code: "repository_source_unavailable", + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }, + ) + expect( + await prisma.securityAudit.count({ + where: { event: "prototype.authorization.policy", prototypeId: prototype.id }, + }), + ).toBe(0) + + await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { status: "active", removedAt: null }, + }) + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).resolves.toMatchObject({ + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }, + ) + + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "hub-managed", + }), + ).resolves.toMatchObject({ + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }, + ) + expect( + await prisma.securityAudit.findMany({ + where: { event: "prototype.authorization.policy", prototypeId: prototype.id }, + orderBy: { createdAt: "asc" }, + select: { + actorUserId: true, + githubRepositorySourceId: true, + context: true, + }, + }), + ).toEqual([ + { + actorUserId: actor.user.id, + githubRepositorySourceId: source.id, + context: { reason: "github_synchronized" }, + }, + { + actorUserId: actor.user.id, + githubRepositorySourceId: null, + context: { reason: "hub_managed" }, + }, + ]) + }) + it("rejects workspace creation by a member without writing product state", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts index 59f639e3..eda189a0 100644 --- a/hub/server/integrations/github/check-repository-admission/constants.ts +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -29,4 +29,5 @@ export const repositorySchema = z.object({ name: z.string().min(1), owner: z.object({ login: z.string().min(1) }), default_branch: z.string().min(1), + visibility: z.enum(["internal", "private", "public"]), }) diff --git a/hub/server/integrations/github/request-github-api/index.ts b/hub/server/integrations/github/request-github-api/index.ts index 4fd5524d..6e9b9268 100644 --- a/hub/server/integrations/github/request-github-api/index.ts +++ b/hub/server/integrations/github/request-github-api/index.ts @@ -24,5 +24,6 @@ export async function requestGitHubApi( throw new GitHubApiError(0) } if (!response.ok) throw new GitHubApiError(response.status) + if (response.status === 204) return null return response.json() } diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts index bc77fd73..40893e1a 100644 --- a/hub/server/routes/product/index.test.ts +++ b/hub/server/routes/product/index.test.ts @@ -38,6 +38,7 @@ function runtime( createPrototype: vi.fn(), getPrototype: vi.fn(), archivePrototype: vi.fn(), + setPrototypeAuthorization: vi.fn(), explainPrototypeAccess: vi.fn(), setPrototypeAccess: vi.fn(), removePrototypeAccess: vi.fn(), @@ -232,6 +233,15 @@ describe("product routes", () => { path: "/api/prototypes/prototype/archive", call: "archivePrototype", }, + { + method: "PUT", + path: "/api/prototypes/prototype/authorization", + call: "setPrototypeAuthorization", + body: { + mode: "github-synchronized", + repositorySourceId: "00000000-0000-0000-0000-000000000001", + }, + }, { method: "GET", path: "/api/prototypes/prototype/access/explanation", @@ -294,6 +304,34 @@ describe("product routes", () => { }, ) + it("forwards prototype authorization changes through the protected HTTP route", async () => { + const saved = { + id: "prototype", + authorizationMode: "github-synchronized", + sourceRepositoryId: "00000000-0000-0000-0000-000000000001", + } + const authentication = runtime({ + setPrototypeAuthorization: vi.fn().mockResolvedValue(saved), + }) + const baseUrl = await serve(authentication) + const body = { + mode: "github-synchronized", + repositorySourceId: saved.sourceRepositoryId, + } + + const response = await fetch(`${baseUrl}/api/prototypes/prototype/authorization`, { + method: "PUT", + headers: protectedHeaders, + body: JSON.stringify(body), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ prototype: saved }) + expect( + authentication.prototypeAuthorization.setPrototypeAuthorization.mock.calls, + ).toContainEqual([sessionToken, "prototype", body]) + }) + it.each([ { name: "missing origin", From 38188ed5e1196d7d1b8dac70c8dc96a4e250fbe2 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 04:01:58 -0700 Subject: [PATCH 07/12] fix(hub): order synchronized permission updates Share per-user ordering across reconciliation and sign-in, preserve valid manual outcomes, batch removal enforcement, and audit synchronized administration preservation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 6 +- hub/docs/prototype-authorization.md | 2 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 545 ++++++++++++++++++ .../create-service/index.ts | 132 ++++- .../create-service/types.ts | 6 + .../create-service/index.db.test.ts | 113 ++++ .../create-service/index.ts | 130 +++-- .../create-service/types.ts | 5 + 9 files changed, 872 insertions(+), 69 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index da36675c..54ed5c58 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -31,15 +31,15 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Live admission permission requests use a separate generation per repository and immutable GitHub subject, so an older response cannot overwrite that user's newer positive or negative confirmation without invalidating reconciliation or another user's sign-in. After GitHub replies, Hub locks and re-reads the relevant generation and applies results only when it is still current. Unlinking, removal webhooks, relinking, dirty webhooks, and newer reconciliation or same-user admission attempts invalidate older in-flight results, so stale replies cannot reactivate a source or replace newer permissions. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. Admission claims before resolving mutable repository metadata and rejects a source-generation change, so a rename or transfer cannot substitute a replacement repository. After GitHub replies, Hub locks and re-reads the relevant generations and applies results only when they are still current. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal marks synchronized records removed immediately. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. ## Staleness and outages A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 removes that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. -Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index acad32eb..b1886589 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -50,7 +50,7 @@ The fixed mapping is: | `read` | `viewer` | | `none` or missing collaborator | No access | -GitHub never supplies Hub `administrator` authority. Manual workspace and prototype grants and denies live in separate tables and synchronization does not update or delete them. +GitHub never supplies Hub `administrator` authority. Manual workspace and prototype grants and denies live in separate tables and synchronization does not update or delete them. Converting a synchronized member to manual membership because a Hub administrator grants product administration writes a structured membership-preservation audit in the same transaction. Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index f2a5515e..b9e1f7e3 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -59,7 +59,7 @@ A dedicated migration case applies the foundation migration, seeds retained life Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation, identity mismatch, built-in role-name mapping, provider and credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and deletion events, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, source-generation changes during metadata resolution, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation shared by reconciliation and sign-in, identity mismatch, built-in role-name mapping, provider-response versus apply failures, credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and batched deletion events, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, long manual reconciliation attribution, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and audited administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 910fe929..6f94e904 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -542,6 +542,90 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ effect: "allow", role: "commenter" }) }) + it("rejects repository metadata observed before a source generation change", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const sourcesObserved = deferred() + const continueAdmission = deferred() + const findMany = prisma.gitHubRepositorySource.findMany.bind(prisma.gitHubRepositorySource) + vi.spyOn(prisma.gitHubRepositorySource, "findMany").mockImplementation(((args) => + findMany(args).then(async (sources) => { + sourcesObserved.resolve() + await continueAdmission.promise + return sources + })) as typeof prisma.gitHubRepositorySource.findMany) + const request = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + return Promise.resolve( + new Response( + JSON.stringify({ + id: 999, + full_name: "original-owner/original-name", + name: "original-name", + owner: { login: "replacement-owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + }) + vi.stubGlobal("fetch", request) + + const admission = service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + await sourcesObserved.promise + await service.processWebhook( + webhook( + { + action: "added", + installation: { id: 7 }, + repository: { + id: 100, + name: "renamed", + owner: { login: "new-owner" }, + default_branch: "main", + visibility: "private", + }, + }, + "member", + "source-generation-changed-before-claim", + ), + ) + continueAdmission.resolve() + + await expect(admission).resolves.toBeNull() + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + ]) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "dirty", + }) + }) + it("does not start another scheduled reconciliation while one is running", async () => { const { prisma, service, store } = await database() await createUser(store, { subject: "42" }) @@ -608,6 +692,94 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("applies a long manual reconciliation after freshness expires and attributes its audit", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "target-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const permissionResponse = deferred() + const permissionRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + permissionRequested.resolve() + return permissionResponse.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { status: 200 }), + ) + }), + ) + + const reconciliation = service.reconcileRepository("administrator-token", source.id) + await permissionRequested.promise + await prisma.session.update({ + where: { id: administrator.session!.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 6 * 60_000) }, + }) + permissionResponse.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + + await expect(reconciliation).resolves.toMatchObject({ + id: source.id, + synchronizationStatus: "current", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: target.session!.id } })).toBeNull() + expect( + await prisma.securityAudit.findFirst({ + where: { event: "github.repository.reconcile", githubRepositorySourceId: source.id }, + }), + ).toMatchObject({ + actorUserId: administrator.user.id, + sessionId: administrator.session!.id, + authenticationProviderRevisionId: administrator.session!.authenticationProviderRevisionId, + }) + }) + it("persists a live denial so a later outage cannot reuse the old positive cache", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { @@ -1354,6 +1526,124 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("records a malformed provider response separately from provider availability", async () => { + const { prisma, service } = await database() + await prisma.gitHubIntegrationState.create({ + data: { id: "00000000-0000-4000-8000-000000000001" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 100, full_name: "owner/repository" }), { + status: 200, + }), + ), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "provider_response_invalid", + }) + }) + + it("records an apply failure and propagates the original internal error", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_reconciliation_apply() RETURNS trigger AS $$ + BEGIN + IF NEW.synchronization_status = 'current' THEN + RAISE EXCEPTION 'synthetic reconciliation apply failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_reconciliation_apply + BEFORE UPDATE ON github_repository_sources + FOR EACH ROW EXECUTE FUNCTION reject_reconciliation_apply(); + `) + onTestFinished(async () => { + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS reject_reconciliation_apply ON github_repository_sources", + ) + await prisma.$executeRawUnsafe("DROP FUNCTION IF EXISTS reject_reconciliation_apply()") + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { status: 200 }), + ) + }), + ) + + await expect(service.reconcileRepository("administrator-token", source.id)).rejects.toThrow( + "synthetic reconciliation apply failure", + ) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "apply_failed", + }) + }) + it("lists repository sources for an administrator without fresh verification", async () => { const { prisma, service, store } = await database() const administrator = await createUser(store, { @@ -1787,6 +2077,132 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) }) + it("keeps a reconciliation denial authoritative when an earlier sign-in responds later", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const signInPermission = deferred() + const signInPermissionRequested = deferred() + let permissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequests += 1 + if (permissionRequests === 1) { + signInPermissionRequested.resolve() + return signInPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const signIn = authentication.complete("code", "state", "browser-token") + await signInPermissionRequested.promise + await service.reconcileDue() + signInPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + + await expect(signIn).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect(permissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + it("does not overwrite an administrator suspension while reconciliation is waiting", async () => { const { prisma, service, store } = await database() await createUser(store, { @@ -2070,6 +2486,135 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.gitHubWebhookDelivery.count()).toBe(0) }) + it.each([ + { + name: "installation deletion", + event: "installation", + payload: { action: "deleted", installation: { id: 7 } }, + }, + { + name: "repository removal", + event: "installation_repositories", + payload: { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + }, + ])("removes suspended sources after $name", async ({ event, payload }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + status: "suspended", + synchronizationStatus: "removed", + }, + }) + + await expect( + service.processWebhook(webhook(payload, event, `suspended-${event}-removal`)), + ).resolves.toBe("accepted") + await expect( + service.processWebhook( + webhook( + { action: "unsuspend", installation: { id: 7 } }, + "installation", + `suspended-${event}-later-unsuspend`, + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + }) + + it("enforces synchronized memberships once after removing multiple sources", async () => { + const { prisma, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const sources = await Promise.all( + ["100", "200"].map((githubRepositoryId) => + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId, + appId: "12345", + installationId: "7", + owner: "owner", + name: `repository-${githubRepositoryId}`, + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ), + ) + let membershipEnforcementQueries = 0 + const observedDatabase = prisma.$extends({ + query: { + installationMembership: { + findMany({ args, query }) { + if (args.where?.source === "synchronized" && args.where.status === "active") { + membershipEnforcementQueries += 1 + } + return query(args) + }, + }, + }, + }) + const observedStore = createIdentityStore(observedDatabase as unknown as PrismaClient) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase as unknown as PrismaClient, + environment: environment(), + store: observedStore, + }) + for (const source of sources) { + await saveAdmissionFixture(prisma, observedService, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + } + + await expect( + observedService.processWebhook( + webhook( + { action: "deleted", installation: { id: 7 } }, + "installation", + "multi-source-installation-delete", + ), + ), + ).resolves.toBe("accepted") + + expect(membershipEnforcementQueries).toBe(1) + expect( + await prisma.gitHubRepositorySource.findMany({ + where: { id: { in: sources.map(({ id }) => id) } }, + select: { status: true }, + }), + ).toEqual([{ status: "removed" }, { status: "removed" }]) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }) + it("suspends linked sources without losing policy and resumes them dirty without baselines", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index a1f1c2e5..35e11e8a 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -222,6 +222,7 @@ async function saveBaseline( select: { generation: true }, }) if (!attempt || attempt.generation !== baseline.admissionGeneration) { + if (skipOlderConfirmation) return "superseded" throw new AuthenticationError( 409, "github_authorization_changed", @@ -366,6 +367,7 @@ async function removeRepositorySource( database: Prisma.TransactionClient, repositorySourceId: string, removedAt: Date, + enforceMemberships = true, ): Promise { await lockRepository(database, repositorySourceId) await database.gitHubRepositorySource.update({ @@ -382,7 +384,7 @@ async function removeRepositorySource( where: { repositorySourceId }, data: { status: "removed", synchronizationStatus: "removed", removedAt }, }) - await enforceSynchronizedMemberships(database, removedAt) + if (enforceMemberships) await enforceSynchronizedMemberships(database, removedAt) } async function suspendInstallationSources( @@ -453,13 +455,25 @@ export function createGitHubAuthorizationService({ return credentials(store, createGitHubIntegrationCredentialProvider(environment)) } - async function claimAdmissionGeneration(repositorySourceId: string, subject: string) { + async function claimAdmissionGeneration( + repositorySourceId: string, + subject: string, + observedSourceGeneration: number, + requireAdmissionEnabled: boolean, + ) { return store.lock(async (_records, transaction) => { await lockRepository(transaction, repositorySourceId) const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) - if (!source || source.status !== "active" || !source.admissionEnabled) return null + if ( + !source || + source.status !== "active" || + source.synchronizationGeneration !== observedSourceGeneration || + (requireAdmissionEnabled && !source.admissionEnabled) + ) { + return null + } const attempt = await transaction.gitHubAdmissionAttempt.upsert({ where: { repositorySourceId_subject: { repositorySourceId, subject } }, create: { repositorySourceId, subject, generation: 1 }, @@ -494,6 +508,7 @@ export function createGitHubAuthorizationService({ }, }) }) + let phase: "credentials" | "token" | "repository" | "permissions" | "apply" = "credentials" try { const integration = await sourceCredentials() if ( @@ -506,10 +521,13 @@ export function createGitHubAuthorizationService({ "The GitHub source integration configuration changed. Relink the repository.", ) } + phase = "token" const token = await createInstallationToken(integration) + phase = "repository" const repository = repositorySchema.parse( await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), ) + phase = "permissions" const identities = [] let identityCursor: string | undefined while (true) { @@ -526,6 +544,13 @@ export function createGitHubAuthorizationService({ } const confirmedAt = new Date() const results = await mapWithConcurrency(identities, 10, async (identity) => { + const claim = await claimAdmissionGeneration( + source.id, + identity.subject, + source.synchronizationGeneration, + false, + ) + if (!claim) return null const rawPermission = await permissionFor( token, identity.username!, @@ -536,10 +561,12 @@ export function createGitHubAuthorizationService({ return { userId: identity.userId, subject: identity.subject, + admissionGeneration: claim.admissionGeneration, rawPermission, ...mapGitHubPermission(rawPermission), } }) + phase = "apply" const applied = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ @@ -553,7 +580,7 @@ export function createGitHubAuthorizationService({ return false } const actor = actorSessionToken - ? await currentAdministrator(store, actorSessionToken, transaction) + ? await currentAdministrator(store, actorSessionToken, transaction, false) : null await transaction.gitHubRepositorySource.update({ where: { id: source.id }, @@ -571,6 +598,7 @@ export function createGitHubAuthorizationService({ }, }) for (const result of results) { + if (!result) continue await saveBaseline( transaction, result.userId, @@ -578,7 +606,7 @@ export function createGitHubAuthorizationService({ repositorySourceId: source.id, sourceGeneration: source.synchronizationGeneration, subject: result.subject, - admissionGeneration: 0, + admissionGeneration: result.admissionGeneration, persist: true, rawPermission: result.rawPermission, admissionEligible: result.admissionEligible, @@ -587,7 +615,7 @@ export function createGitHubAuthorizationService({ }, false, true, - false, + true, ) } await enforceSynchronizedMemberships(transaction, confirmedAt) @@ -638,25 +666,36 @@ export function createGitHubAuthorizationService({ } return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) } - const failed = await database.gitHubRepositorySource.updateMany({ - where: { - id: source.id, - status: "active", - synchronizationGeneration: source.synchronizationGeneration, - }, - data: { - synchronizationStatus: "failed", - synchronizationError: - error instanceof GitHubApiError - ? `github_${error.status}` - : error instanceof AuthenticationError - ? error.code - : "provider_unavailable", - }, - }) + const synchronizationError = + phase === "apply" + ? "apply_failed" + : error instanceof GitHubApiError + ? `github_${error.status}` + : error instanceof AuthenticationError + ? error.code + : ["token", "repository", "permissions"].includes(phase) + ? "provider_response_invalid" + : "provider_unavailable" + let failed + try { + failed = await database.gitHubRepositorySource.updateMany({ + where: { + id: source.id, + status: "active", + synchronizationGeneration: source.synchronizationGeneration, + }, + data: { + synchronizationStatus: "failed", + synchronizationError, + }, + }) + } catch { + throw error + } if (failed.count === 0) { return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) } + if (phase === "apply") throw error if (error instanceof AuthenticationError) throw error throw new AuthenticationError( 503, @@ -749,6 +788,13 @@ export function createGitHubAuthorizationService({ continue } try { + const claim = await claimAdmissionGeneration( + source.id, + profile.subject, + source.synchronizationGeneration, + true, + ) + if (!claim) continue let repository: z.infer try { repository = repositorySchema.parse( @@ -772,8 +818,6 @@ export function createGitHubAuthorizationService({ } throw error } - const claim = await claimAdmissionGeneration(source.id, profile.subject) - if (!claim) continue const rawPermission = await permissionFor( token, profile.username, @@ -911,6 +955,30 @@ export function createGitHubAuthorizationService({ return [...new Set(prototypes.map(({ workspaceId }) => workspaceId))] }, + async prototypeIds(transaction, userId, workspaceId, now = new Date()) { + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const prototypes = await transaction.prototype.findMany({ + where: { + workspaceId, + authorizationMode: "github-synchronized", + grants: { none: { userId, effect: "deny" } }, + sourceRepository: { + status: "active", + synchronizedAccess: { + some: { + userId, + status: "active", + mappedPrototypeRole: { not: null }, + lastConfirmedAt: { gte: cutoff }, + }, + }, + }, + }, + select: { id: true }, + }) + return prototypes.map(({ id }) => id) + }, + async listRepositories(sessionToken) { await currentAdministrator(store, sessionToken, undefined, false) return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) @@ -1179,12 +1247,16 @@ export function createGitHubAuthorizationService({ const now = new Date() if (event === "installation" && payload.data.action === "deleted") { const sources = await transaction.gitHubRepositorySource.findMany({ - where: { installationId: integration.installationId, status: "active" }, + where: { + installationId: integration.installationId, + status: { in: ["active", "suspended"] }, + }, select: { id: true }, }) for (const source of sources) { - await removeRepositorySource(transaction, source.id, now) + await removeRepositorySource(transaction, source.id, now, false) } + await enforceSynchronizedMemberships(transaction, now) } else if (event === "installation" && payload.data.action === "suspend") { await suspendInstallationSources(transaction, integration.installationId, now) } else if (event === "installation" && payload.data.action === "unsuspend") { @@ -1202,12 +1274,16 @@ export function createGitHubAuthorizationService({ if (repositoryId) dirtyIds.add(String(repositoryId)) if (removedIds.size > 0) { const removedSources = await transaction.gitHubRepositorySource.findMany({ - where: { githubRepositoryId: { in: [...removedIds] }, status: "active" }, + where: { + githubRepositoryId: { in: [...removedIds] }, + status: { in: ["active", "suspended"] }, + }, select: { id: true }, }) for (const source of removedSources) { - await removeRepositorySource(transaction, source.id, now) + await removeRepositorySource(transaction, source.id, now, false) } + await enforceSynchronizedMemberships(transaction, now) } if (dirtyIds.size > 0) { await transaction.gitHubRepositorySource.updateMany({ diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts index 66328656..9c8c6ab1 100644 --- a/hub/server/domains/github-authorization/create-service/types.ts +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -39,6 +39,12 @@ export interface GitHubAuthorizationService { userId: string, now?: Date, ): Promise + prototypeIds( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + workspaceId: string, + now?: Date, + ): Promise listRepositories(sessionToken: string): Promise linkRepository(sessionToken: string, input: unknown): Promise updateRepository( diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 5e51ed03..dd1522c4 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -2054,6 +2054,29 @@ describe("createPrototypeAuthorizationService() database behavior", () => { where: { userId: prototypeTarget.user.id }, }), ).toMatchObject({ source: "manual", status: "active" }) + expect( + await prisma.securityAudit.findMany({ + where: { event: "membership.synchronized.preserve" }, + orderBy: { createdAt: "asc" }, + }), + ).toEqual([ + expect.objectContaining({ + actorUserId: actor.user.id, + sessionId: actor.session.id, + authenticationProviderRevisionId: actor.session.authenticationProviderRevisionId, + targetMembershipId: workspaceTarget.membership.id, + targetUserId: workspaceTarget.user.id, + context: { reason: "product_administration" }, + }), + expect.objectContaining({ + actorUserId: actor.user.id, + sessionId: actor.session.id, + authenticationProviderRevisionId: actor.session.authenticationProviderRevisionId, + targetMembershipId: prototypeTarget.membership.id, + targetUserId: prototypeTarget.user.id, + context: { reason: "product_administration" }, + }), + ]) }) it("passes the mutation transaction to a synchronized prototype baseline", async () => { @@ -2153,6 +2176,96 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ]) }) + it("uses batched baseline prototype IDs without per-prototype authorization queries", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const baselinePrototypeIds = vi.fn>() + const { prisma, service, store } = await database({ + baseline: baselineAccess, + prototypeIds: baselinePrototypeIds, + }) + const actor = await createActor(store, { + subject: "prototype-baseline-batch", + token: "prototype-baseline-batch-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const directId = "00000000-0000-4000-8000-000000000010" + const baselineId = "00000000-0000-4000-8000-000000000020" + await prisma.prototype.createMany({ + data: [ + { + id: directId, + name: "Direct", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + { + id: baselineId, + name: "Synchronized", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + ...Array.from({ length: 5 }, (_, index) => ({ + id: `00000000-0000-4000-8000-00000000010${index}`, + name: `Unrelated ${index}`, + workspaceId: workspace.id, + createdByUserId: actor.user.id, + })), + ], + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: directId, + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }) + baselinePrototypeIds.mockResolvedValue([baselineId]) + const prototypePageQueries = vi.spyOn(prisma.prototype, "findMany") + const anyPrototypeQueries = vi.spyOn(prisma.prototype, "findFirst") + + await expect( + service.getWorkspace("prototype-baseline-batch-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.listPrototypes("prototype-baseline-batch-token", workspace.id, {}), + ).resolves.toMatchObject({ + items: [{ id: directId }, { id: baselineId }], + nextCursor: null, + }) + + expect(baselinePrototypeIds).toHaveBeenCalledTimes(3) + expect( + baselinePrototypeIds.mock.calls.every( + ([database, userId, workspaceId]) => + database === prisma && userId === actor.user.id && workspaceId === workspace.id, + ), + ).toBe(true) + expect(baselineAccess).toHaveBeenCalledOnce() + expect(baselineAccess).toHaveBeenCalledWith(prisma, actor.user.id, baselineId) + const anyQuery = JSON.stringify(anyPrototypeQueries.mock.calls[0]?.[0]) + expect(anyQuery).toContain(workspace.id) + expect(anyQuery).toContain(actor.user.id) + expect(anyQuery).toContain(baselineId) + expect(prototypePageQueries).toHaveBeenCalledOnce() + const pageQuery = JSON.stringify(prototypePageQueries.mock.calls[0]?.[0]) + expect(pageQuery).toContain(workspace.id) + expect(pageQuery).toContain(actor.user.id) + expect(pageQuery).toContain(baselineId) + }) + it("gets a workspace through prototype or baseline access while an explicit workspace deny wins", async () => { const baselineUsers = new Set() const baselineAccess = vi diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index f35e34eb..a43a801e 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -158,14 +158,30 @@ async function requireActiveTarget(database: Prisma.TransactionClient, userId: s async function preserveManualAdministration( database: Prisma.TransactionClient, + actor: Awaited>, userId: string, change: GrantInput, ): Promise { if (change.effect !== "allow" || change.role !== "administrator") return - await database.installationMembership.updateMany({ - where: { userId, source: "synchronized", status: "active" }, + const membership = await database.installationMembership.findUnique({ + where: { userId }, + select: { id: true, source: true, status: true }, + }) + if (membership?.source !== "synchronized" || membership.status !== "active") return + const changed = await database.installationMembership.updateMany({ + where: { id: membership.id, source: "synchronized", status: "active" }, data: { source: "manual" }, }) + if (changed.count === 0) return + await saveSecurityAudit(database, { + event: "membership.synchronized.preserve", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + targetMembershipId: membership.id, + targetUserId: userId, + context: { reason: "product_administration" }, + }) } async function lockWorkspace( @@ -450,10 +466,11 @@ async function readAccessiblePrototypePage({ cursor?: string limit: number }) { - if (!baseline) { - const accessWhere = workspaceGrant - ? { grants: { none: { userId, effect: "deny" as const } } } - : { grants: { some: { userId, effect: "allow" as const } } } + if (workspaceGrant?.effect === "allow" || !baseline) { + const accessWhere = + workspaceGrant?.effect === "allow" + ? { grants: { none: { userId, effect: "deny" as const } } } + : { grants: { some: { userId, effect: "allow" as const } } } const items = await database.prototype.findMany({ where: { workspaceId, @@ -465,11 +482,46 @@ async function readAccessiblePrototypePage({ }) return pageResult(items.map(prototypeDto), limit) } + const baselinePrototypeIds = baseline.prototypeIds + ? await baseline.prototypeIds(database, userId, workspaceId) + : [] + if (!baseline.prototypeIds) { + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.prototype.findMany({ + where: { workspaceId, ...(scanCursor ? { id: { gt: scanCursor } } : {}) }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + if ( + (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) + .allowed + ) { + items.push(prototypeDto(candidate)) + } + if (items.length > limit) break + } + if (candidates.length < 100) break + } + return pageResult(items, limit) + } const items = [] let scanCursor = cursor while (items.length <= limit) { const candidates = await database.prototype.findMany({ - where: { workspaceId, ...(scanCursor ? { id: { gt: scanCursor } } : {}) }, + where: { + workspaceId, + ...(scanCursor ? { id: { gt: scanCursor } } : {}), + OR: [ + { grants: { some: { userId, effect: "allow" } } }, + ...(baselinePrototypeIds.length > 0 ? [{ id: { in: baselinePrototypeIds } }] : []), + ], + }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, take: 100, @@ -507,36 +559,42 @@ async function hasAnyPrototypeAccess({ }) { const workspaceDecision = evaluatePrototypeAccess({ workspaceGrant }) if (workspaceDecision.source !== "none") return workspaceDecision.allowed - if (!baseline) { - return Boolean( - await database.prototype.findFirst({ - where: { - workspaceId, - grants: { some: { userId, effect: "allow" } }, - }, - select: { id: true }, - }), - ) - } - let cursor: string | undefined - while (true) { - const candidates = await database.prototype.findMany({ - where: { workspaceId, ...(cursor ? { id: { gt: cursor } } : {}) }, - include: { grants: { where: { userId } } }, - orderBy: { id: "asc" }, - take: 100, - }) - for (const candidate of candidates) { - if ( - (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) - .allowed - ) { - return true + const baselinePrototypeIds = baseline?.prototypeIds + ? await baseline.prototypeIds(database, userId, workspaceId) + : [] + if (baseline && !baseline.prototypeIds) { + let cursor: string | undefined + while (true) { + const candidates = await database.prototype.findMany({ + where: { workspaceId, ...(cursor ? { id: { gt: cursor } } : {}) }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + for (const candidate of candidates) { + if ( + (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) + .allowed + ) { + return true + } } + if (candidates.length < 100) return false + cursor = candidates.at(-1)?.id } - if (candidates.length < 100) return false - cursor = candidates.at(-1)?.id } + return Boolean( + await database.prototype.findFirst({ + where: { + workspaceId, + OR: [ + { grants: { some: { userId, effect: "allow" } } }, + ...(baselinePrototypeIds.length > 0 ? [{ id: { in: baselinePrototypeIds } }] : []), + ], + }, + select: { id: true }, + }), + ) } async function audit( @@ -680,7 +738,7 @@ export function createPrototypeAuthorizationService({ create: { workspaceId, userId: targetId, ...change }, update: change, }) - await preserveManualAdministration(transaction, targetUserId, change) + await preserveManualAdministration(transaction, actor, targetId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", workspaceId, @@ -940,7 +998,7 @@ export function createPrototypeAuthorizationService({ create: { prototypeId, userId: targetId, ...change }, update: change, }) - await preserveManualAdministration(transaction, targetId, change) + await preserveManualAdministration(transaction, actor, targetId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant", workspaceId: prototype.workspaceId, diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index 13cfc46f..ba9ad592 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -62,4 +62,9 @@ export interface PrototypeBaselinePolicy { database: Prisma.TransactionClient | PrismaClient, userId: string, ): Promise + prototypeIds?( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + workspaceId: string, + ): Promise } From 8e01f809692d18f5ed01c2ff65085014c26292ab Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 04:40:04 -0700 Subject: [PATCH 08/12] fix(hub): preserve reconciliation ordering Share per-user ordering across scans and sign-ins, filter enforcement candidates, retain partial confirmations, and log bounded scheduled failures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 4 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 390 +++++++++++++++++- .../create-service/index.ts | 206 ++++----- 4 files changed, 501 insertions(+), 101 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index 54ed5c58..a6515518 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -31,7 +31,7 @@ GitHub does not map to Hub installation administrator or prototype administrator `POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. -Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. Admission claims before resolving mutable repository metadata and rejects a source-generation change, so a rename or transfer cannot substitute a replacement repository. After GitHub replies, Hub locks and re-reads the relevant generations and applies results only when they are still current. +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. These per-user claims use the repository row lock without taking the global identity-store lock. Admission claims before resolving mutable repository metadata and returns a retryable conflict when the source generation changed, so a rename or transfer cannot substitute a replacement repository or appear as permission loss. `POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. @@ -39,7 +39,7 @@ Each reconciliation claims a monotonically increasing source generation while ho A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 removes that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. -Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. One user's provider failure leaves that user's timestamp unchanged while other confirmed results apply; the source records a bounded partial-failure status and scheduled runs emit one static operational error record with the source ID and bounded reason. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index b9e1f7e3..7174362a 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -59,7 +59,7 @@ A dedicated migration case applies the foundation migration, seeds retained life Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, source-generation changes during metadata resolution, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation shared by reconciliation and sign-in, identity mismatch, built-in role-name mapping, provider-response versus apply failures, credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and batched deletion events, replayed deliveries, missed-event repair, bounded request concurrency, single-flight scheduling, long manual reconciliation attribution, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and audited administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, retryable source-generation changes during metadata resolution, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation shared by reconciliation and sign-in, first-enrollment rollback, per-user claim lock isolation, identity mismatch and partial provider failure, built-in role-name mapping, provider-response versus apply failures, credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and batched deletion events, replayed deliveries, missed-event repair, bounded request concurrency, filtered membership enforcement, single-flight scheduling, long manual reconciliation attribution and scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and audited administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 6f94e904..ab05854f 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -10,9 +10,11 @@ import type { GitHubAuthorizationService, } from "#server/domains/github-authorization/create-service/types" import { createIdentityStore } from "#server/domains/identity/create-store" +import { logger } from "#server/integrations/pino" +import { AuthenticationError } from "#server/utils/authentication-error" import { digestSecret } from "#server/utils/digest-secret" -import type { PrismaClient } from "../../../generated/prisma/client.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" type IdentityStore = ReturnType @@ -542,7 +544,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ effect: "allow", role: "commenter" }) }) - it("rejects repository metadata observed before a source generation change", async () => { + it("reports a source generation change instead of treating admission as denied", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) const source = await prisma.gitHubRepositorySource.create({ @@ -614,7 +616,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { ) continueAdmission.resolve() - await expect(admission).resolves.toBeNull() + await expect(admission).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ "https://api.github.com/app/installations/7/access_tokens", ]) @@ -626,6 +631,66 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("claims reconciliation attempts without waiting for the identity-store lock", async () => { + const { prisma, service, store } = await database() + await createUser(store, { subject: "42" }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const lockHeld = deferred() + const releaseLock = deferred() + let permissionRequested = false + const lock = store.lock(async () => { + lockHeld.resolve() + await releaseLock.promise + }) + await lockHeld.promise + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequested = true + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), + ) + }), + ) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => expect(permissionRequested).toBe(true)) + await expect(prisma.gitHubRepositorySource.count()).resolves.toBe(1) + + releaseLock.resolve() + await Promise.all([lock, reconciliation]) + }) + it("does not start another scheduled reconciliation while one is running", async () => { const { prisma, service, store } = await database() await createUser(store, { subject: "42" }) @@ -1472,6 +1537,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { it("records source configuration mismatch as failed without refreshing confirmation", async () => { const { prisma, service, store } = await database() await store.lock(() => Promise.resolve()) + const errorLog = vi.spyOn(logger, "error").mockImplementation(() => undefined) const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") const source = await prisma.gitHubRepositorySource.create({ data: { @@ -1495,6 +1561,13 @@ describe("createGitHubAuthorizationService() database behavior", () => { synchronizationError: "repository_source_configuration_changed", lastConfirmedAt, }) + expect(errorLog).toHaveBeenCalledWith( + { + repositorySourceId: source.id, + reason: "repository_source_configuration_changed", + }, + "GitHub authorization reconciliation failed", + ) }) it("records credential reconciliation failure without refreshing confirmation", async () => { @@ -1947,6 +2020,219 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("keeps successful confirmations when another identity permission check fails", async () => { + const { prisma, service, store } = await database() + const confirmed = await createUser(store, { subject: "42" }) + const failed = await createUser(store, { subject: "43" }) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + await saveAdmissionFixture(prisma, service, failed.user.id, failed.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: lastConfirmedAt, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { + status: 200, + }), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: confirmed.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + admissionEligible: true, + mappedPrototypeRole: "viewer", + status: "active", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: failed.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + mappedPrototypeRole: "editor", + lastConfirmedAt, + status: "active", + }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "permission_partial_failure", + lastConfirmedAt, + }) + }) + + it("rolls back first synchronized enrollment when its baseline is superseded", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + }), + ) + const saveBaseline = vi.fn(async (transaction: Prisma.TransactionClient) => { + await expect( + transaction.installationMembership.findUnique({ + where: { id: target.membership.id }, + }), + ).resolves.toMatchObject({ source: "synchronized", status: "active" }) + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed.", + ) + }) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline, + }, + }) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + + expect(saveBaseline).toHaveBeenCalledOnce() + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: target.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + it("keeps a newer denied sign-in authoritative when an older grant responds later", async () => { const { prisma, service, store } = await database() const target = await createUser(store, { @@ -2615,6 +2901,104 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() }) + it("excludes fresh eligible users in the initial membership enforcement query", async () => { + const { prisma, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const [removedSource, eligibleSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "removed", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "eligible", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + let membershipQueries = 0 + let membershipCandidates = -1 + let baselineLookupQueries = 0 + let membershipWhere: unknown + const observedDatabase = prisma.$extends({ + query: { + installationMembership: { + async findMany({ args, query }) { + membershipQueries += 1 + membershipWhere = args.where + const result = await query(args) + membershipCandidates = result.length + return result + }, + }, + gitHubSynchronizedAccess: { + findFirst({ args, query }) { + baselineLookupQueries += 1 + return query(args) + }, + }, + }, + }) + const observedStore = createIdentityStore(observedDatabase as unknown as PrismaClient) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase as unknown as PrismaClient, + environment: environment(), + store: observedStore, + }) + await saveAdmissionFixture(prisma, observedService, actor.user.id, actor.identity.subject, { + repositorySourceId: eligibleSource.id, + sourceGeneration: eligibleSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + + await expect( + observedService.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: Number(removedSource.githubRepositoryId) }], + }, + "installation_repositories", + "preserve-fresh-eligible-membership", + ), + ), + ).resolves.toBe("accepted") + + expect(membershipQueries).toBe(1) + expect(membershipCandidates).toBe(0) + expect(baselineLookupQueries).toBe(0) + expect(JSON.stringify(membershipWhere)).toContain('"source":"synchronized"') + expect(JSON.stringify(membershipWhere)).toContain('"status":"active"') + expect(JSON.stringify(membershipWhere)).toContain('"admissionEligible":true') + expect(JSON.stringify(membershipWhere)).toContain('"admissionEnabled":true') + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "active" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).not.toBeNull() + }) + it("suspends linked sources without losing policy and resumes them dirty without baselines", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 35e11e8a..0e3ca204 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -20,6 +20,7 @@ import { } from "#server/integrations/github/check-repository-admission/constants" import { createInstallationToken } from "#server/integrations/github/create-installation-token" import { GitHubApiError } from "#server/integrations/github/github-api-error" +import { log } from "#server/integrations/pino/log" import { requestGitHubApi } from "#server/integrations/github/request-github-api" import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" @@ -284,82 +285,80 @@ async function enforceSynchronizedMemberships( ): Promise { const cutoff = new Date(at.getTime() - (await lastKnownGoodSeconds(database)) * 1_000) const synchronizedMemberships = await database.installationMembership.findMany({ - where: { source: "synchronized", status: "active" }, + where: { + source: "synchronized", + status: "active", + user: { + githubSynchronizedAccess: { + none: { + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + }, + }, + }, select: { id: true, userId: true, role: true }, }) for (const membership of synchronizedMemberships) { - const eligible = await database.gitHubSynchronizedAccess.findFirst({ - where: { - userId: membership.userId, - status: "active", - admissionEligible: true, - lastConfirmedAt: { gte: cutoff }, - repositorySource: { status: "active", admissionEnabled: true }, - }, - }) - if (!eligible) { - const [workspaceAdministration, prototypeAdministration] = await Promise.all([ - database.workspaceAccessGrant.findFirst({ - where: { - userId: membership.userId, - effect: "allow", - role: "administrator", - }, - select: { id: true }, - }), - database.prototypeAccessGrant.findFirst({ - where: { - userId: membership.userId, - effect: "allow", - role: "administrator", - }, - select: { id: true }, - }), - ]) - if ( - membership.role === "administrator" || - workspaceAdministration || - prototypeAdministration - ) { - const preserved = await database.installationMembership.updateMany({ - where: { - id: membership.id, - source: "synchronized", - status: "active", - }, - data: { source: "manual" }, - }) - if (preserved.count > 0) { - await saveSecurityAudit(database, { - event: "membership.synchronized.preserve", - actorUserId: null, - sessionId: null, - authenticationProviderRevisionId: null, - targetMembershipId: membership.id, - context: { reason: "manual_administration" }, - }) - } - continue - } - const removed = await database.installationMembership.updateMany({ + const [workspaceAdministration, prototypeAdministration] = await Promise.all([ + database.workspaceAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + database.prototypeAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + ]) + if (membership.role === "administrator" || workspaceAdministration || prototypeAdministration) { + const preserved = await database.installationMembership.updateMany({ where: { id: membership.id, source: "synchronized", status: "active", }, - data: { status: "removed", removedAt: at }, - }) - if (removed.count === 0) continue - await database.session.deleteMany({ where: { userId: membership.userId } }) - await saveSecurityAudit(database, { - event: "membership.synchronized.remove", - actorUserId: null, - sessionId: null, - authenticationProviderRevisionId: null, - targetMembershipId: membership.id, - context: { reason: "github_access_lost" }, + data: { source: "manual" }, }) + if (preserved.count > 0) { + await saveSecurityAudit(database, { + event: "membership.synchronized.preserve", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "manual_administration" }, + }) + } + continue } + const removed = await database.installationMembership.updateMany({ + where: { + id: membership.id, + source: "synchronized", + status: "active", + }, + data: { status: "removed", removedAt: at }, + }) + if (removed.count === 0) continue + await database.session.deleteMany({ where: { userId: membership.userId } }) + await saveSecurityAudit(database, { + event: "membership.synchronized.remove", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) } } @@ -461,25 +460,22 @@ export function createGitHubAuthorizationService({ observedSourceGeneration: number, requireAdmissionEnabled: boolean, ) { - return store.lock(async (_records, transaction) => { + return database.$transaction(async (transaction) => { await lockRepository(transaction, repositorySourceId) const source = await transaction.gitHubRepositorySource.findUnique({ where: { id: repositorySourceId }, }) - if ( - !source || - source.status !== "active" || - source.synchronizationGeneration !== observedSourceGeneration || - (requireAdmissionEnabled && !source.admissionEnabled) - ) { - return null + if (!source || source.status !== "active") return { status: "denied" as const } + if (requireAdmissionEnabled && !source.admissionEnabled) return { status: "denied" as const } + if (source.synchronizationGeneration !== observedSourceGeneration) { + return { status: "changed" as const } } const attempt = await transaction.gitHubAdmissionAttempt.upsert({ where: { repositorySourceId_subject: { repositorySourceId, subject } }, create: { repositorySourceId, subject, generation: 1 }, update: { generation: { increment: 1 } }, }) - return { source, admissionGeneration: attempt.generation } + return { status: "claimed" as const, source, admissionGeneration: attempt.generation } }) } @@ -550,22 +546,28 @@ export function createGitHubAuthorizationService({ source.synchronizationGeneration, false, ) - if (!claim) return null - const rawPermission = await permissionFor( - token, - identity.username!, - identity.subject, - repository.full_name, - repository.visibility, - ) - return { - userId: identity.userId, - subject: identity.subject, - admissionGeneration: claim.admissionGeneration, - rawPermission, - ...mapGitHubPermission(rawPermission), + if (claim.status !== "claimed") return null + try { + const rawPermission = await permissionFor( + token, + identity.username!, + identity.subject, + repository.full_name, + repository.visibility, + ) + return { + status: "confirmed" as const, + userId: identity.userId, + subject: identity.subject, + admissionGeneration: claim.admissionGeneration, + rawPermission, + ...mapGitHubPermission(rawPermission), + } + } catch { + return { status: "failed" as const } } }) + const partialFailure = results.some((result) => result?.status === "failed") phase = "apply" const applied = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) @@ -591,14 +593,14 @@ export function createGitHubAuthorizationService({ visibility: repository.visibility, status: "active", removedAt: null, - synchronizationStatus: "current", - synchronizationError: null, + synchronizationStatus: partialFailure ? "failed" : "current", + synchronizationError: partialFailure ? "permission_partial_failure" : null, dirtyAt: null, - lastConfirmedAt: confirmedAt, + ...(partialFailure ? {} : { lastConfirmedAt: confirmedAt }), }, }) for (const result of results) { - if (!result) continue + if (!result || result.status !== "confirmed") continue await saveBaseline( transaction, result.userId, @@ -794,7 +796,14 @@ export function createGitHubAuthorizationService({ source.synchronizationGeneration, true, ) - if (!claim) continue + if (claim.status === "changed") { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + if (claim.status === "denied") continue let repository: z.infer try { repository = repositorySchema.parse( @@ -1159,7 +1168,14 @@ export function createGitHubAuthorizationService({ try { await reconcile(source.id) } catch { - // The source record stores the explicit failure and bounded last-known-good state. + const failed = await database.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + select: { synchronizationError: true }, + }) + log("error", "GitHub authorization reconciliation failed", { + repositorySourceId: source.id, + reason: failed?.synchronizationError ?? "reconciliation_failed", + }) } } await store.lock((_records, transaction) => From cd3d7120846cf12e12c16b91fca6194c17ca49fc Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 07:57:53 -0700 Subject: [PATCH 09/12] fix(hub): close synchronized revocation races Revalidate cached admission transactionally, order per-result confirmations, revoke without source credentials, bound reconciliation work, and expose prototype policy state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 6 +- hub/docs/prototype-authorization.md | 2 + hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 67 ++ .../create-service/index.db.test.ts | 693 +++++++++++++++++- .../create-service/index.ts | 216 ++++-- .../create-service/index.db.test.ts | 29 +- .../create-service/index.ts | 6 + 8 files changed, 973 insertions(+), 48 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index a6515518..686360b8 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -33,13 +33,13 @@ GitHub does not map to Hub installation administrator or prototype administrator Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. These per-user claims use the repository row lock without taking the global identity-store lock. Admission claims before resolving mutable repository metadata and returns a retryable conflict when the source generation changed, so a rename or transfer cannot substitute a replacement repository or appear as permission loss. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads for the configured installation. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. Installation suspension preserves linked source configuration and admission switches while revoking synchronized state; unsuspension marks those sources active and dirty for reconciliation without restoring old baselines. Delivery IDs are unique, so replayed payloads are harmless. Webhooks do not grant access by themselves; reconciliation confirms it. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Revocation events match the signed installation ID against trusted linked-source records and do not require usable API credentials, so credential loss cannot block deletion or suspension. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Dirty events are coalesced into a bounded near-term retry so a scan superseded by frequent organization changes does not wait for the next 15-minute tick. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. ## Staleness and outages -A successful confirmation records `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. Credential and configuration failures record an explicit failed status without refreshing that time. Hub permits a last-known-good baseline for one day, then fails closed. Admission evaluates live and cached results per repository: a fresh denial or repository 404 removes that source's old cache, while a token-service outage may use only the still-fresh cache for a source whose configured App and installation IDs still match. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. Reconciliation bounds concurrent GitHub permission requests, treats an immutable-identity mismatch as denial for only that identity, and serializes membership removal with sign-in and administrator changes. +A successful confirmation records its own `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. A cache fallback is revalidated under the callback transaction before it can reactivate membership or create a session. Installation-token 404 removes matching sources rather than using cache. Credential and configuration failures record an explicit failed status without refreshing confirmation. Hub permits a last-known-good baseline for one day, then fails closed. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. -Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. One user's provider failure leaves that user's timestamp unchanged while other confirmed results apply; the source records a bounded partial-failure status and scheduled runs emit one static operational error record with the source ID and bounded reason. +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. Scans include only identities with active Hub membership, issue permission requests with bounded concurrency, and apply confirmations in bounded transactions outside the global identity lock. One user's provider failure leaves that user's timestamp unchanged while other confirmed results apply; the source records a bounded partial-failure status and scheduled runs emit one static operational error record with the source ID and bounded reason. Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index b1886589..5ead8eca 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -41,6 +41,8 @@ The evaluator applies this order: Every decision reports whether access is allowed, the effective role, the source, and a stable reason. GitHub baseline decisions also report the repository source, immutable GitHub repository ID, raw permission, last confirmation time, and synchronization status. Routes use this evaluator instead of reimplementing precedence. Hub consults the synchronized baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. +Prototype read and list responses include `authorizationMode` and `sourceRepositoryId`. The authorization-policy mutation returns that same prototype shape, so administrators can inspect the effective configuration even when an explicit Hub role wins the access decision. + The fixed mapping is: | GitHub permission | Hub prototype baseline | diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 7174362a..4a24ff39 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -59,7 +59,7 @@ A dedicated migration case applies the foundation migration, seeds retained life Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. -GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover opt-in admission, invitation precedence during source outage, immutable-ID rename and 404 invalidation, retryable source-generation changes during metadata resolution, repository removal and installation delete/suspend/unsuspend, ordered positive and negative permission confirmation shared by reconciliation and sign-in, first-enrollment rollback, per-user claim lock isolation, identity mismatch and partial provider failure, built-in role-name mapping, provider-response versus apply failures, credential outage, App/installation-aware cache fallback, stable linking errors, webhook signature byte handling, ping, dirty and batched deletion events, replayed deliveries, missed-event repair, bounded request concurrency, filtered membership enforcement, single-flight scheduling, long manual reconciliation attribution and scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, unlink during reconciliation, manual suspension and audited administration preservation, compatibility membership migration and isolation, synchronized session freshness and revocation, and reconciliation rollback. Tests must assert that background synchronization requests use installation tokens from the source-integration credential provider and never accept a browser user token. +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover transactional cache revalidation, invitation precedence during source outage, immutable-ID rename and 404 invalidation, token-404 installation removal, credential-independent webhook revocation, retryable source-generation changes, per-result confirmation ordering, active-identity filtering, bounded provider requests and apply batches, partial scans, dirty-event retry coalescing, first-enrollment and admission-failure auditing, repository and installation lifecycle, provider-response versus apply failures, scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, manual suspension and audited administration preservation, compatibility membership isolation, synchronized session freshness, and rollback. The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts index 5ceb436a..02d75230 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -413,6 +413,73 @@ describe("createAuthenticationService() database behavior", () => { }, ) + it("commits an admission-policy failure audit before returning the provider error", async () => { + const { prisma, store } = await database() + const graph = await createAdministrator(store) + const now = new Date() + await prisma.installationMembership.update({ + where: { userId: graph.user.id }, + data: { role: "member", source: "synchronized" }, + }) + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(now.getTime() + 60_000), + }), + ) + const service = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: graph.identity.subject, + username: "octocat", + displayName: "Synthetic administrator", + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: graph.provider.id, + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockRejectedValue( + Object.assign(new Error("GitHub authorization changed."), { + status: 409, + code: "github_authorization_changed", + }), + ), + }, + now: () => now, + }) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + expect( + await prisma.securityAudit.findFirst({ + where: { event: "authentication.admission_failed" }, + }), + ).toMatchObject({ + outcome: "failed", + actorUserId: graph.user.id, + authenticationProviderRevisionId: graph.provider.id, + context: { reason: "source_integration_unavailable" }, + }) + expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(0) + }) + it("rolls back generation, session deletion, and audit together when revocation fails", async () => { const { store } = await database() const graph = await createAdministrator(store) diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index ab05854f..bffa8315 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks" import { createHmac, generateKeyPairSync } from "node:crypto" import { readFileSync } from "node:fs" import { describe, expect, it, onTestFinished, vi } from "vitest" @@ -2368,7 +2369,6 @@ describe("createGitHubAuthorizationService() database behavior", () => { const target = await createUser(store, { subject: "42", membershipSource: "synchronized", - status: "removed", }) const source = await prisma.gitHubRepositorySource.create({ data: { @@ -2580,7 +2580,6 @@ describe("createGitHubAuthorizationService() database behavior", () => { const target = await createUser(store, { subject: "42", membershipSource: "synchronized", - status: "removed", }) const lost = await createUser(store, { subject: "43", @@ -3164,6 +3163,696 @@ describe("createGitHubAuthorizationService() database behavior", () => { ).toMatchObject({ actorUserId: administrator.user.id }) }) + it("revalidates cached fallback in the callback transaction after synchronized revocation", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "existing-session", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("fallback-state"), + browserDigest: digestSecret("fallback-browser"), + pkceVerifier: "fallback-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: async (profile) => { + const baseline = await service.evaluateAdmission(profile) + await service.processWebhook( + webhook( + { action: "deleted", installation: { id: 7 } }, + "installation", + "fallback-revocation", + ), + ) + return baseline + }, + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + await expect( + authentication.complete("code", "fallback-state", "fallback-browser"), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect(await prisma.session.count({ where: { userId: actor.user.id } })).toBe(0) + }) + + it("removes matching sources and synchronized membership before rejecting token 404 fallback", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-session", + }) + const [matchingSource, otherSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "matching", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "99999", + installationId: "8", + owner: "owner", + name: "other", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: matchingSource.id, + sourceGeneration: matchingSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 }))) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: matchingSource.id } }), + ).toMatchObject({ status: "removed", admissionEnabled: false }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: otherSource.id } }), + ).toMatchObject({ status: "active", admissionEnabled: true }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: matchingSource.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }) + + it.each([ + { action: "deleted", expectedSourceStatus: "removed" }, + { action: "suspend", expectedSourceStatus: "suspended" }, + ] as const)( + "uses the trusted linked installation for a signed $action webhook when credentials are unreadable", + async ({ action, expectedSourceStatus }) => { + const { prisma, service, store } = await database({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "database", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: `${action}-session`, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await store.lock((records) => + records.saveGitHubIntegrationCredentials("invalid-encrypted-credentials"), + ) + + await expect( + service.processWebhook( + webhook( + { action, installation: { id: 7 } }, + "installation", + `unreadable-credentials-${action}`, + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ status: expectedSourceStatus }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }, + ) + + it("reconciles only GitHub identities with an active installation membership", async () => { + const { prisma, service, store } = await database() + const active = await createUser(store, { subject: "42" }) + await createUser(store, { subject: "43", status: "suspended" }) + await createUser(store, { subject: "44", status: "removed" }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const permissionRequests: string[] = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequests.push(url) + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), + ) + }), + ) + + await service.reconcileDue() + + expect(permissionRequests).toHaveLength(1) + expect(permissionRequests[0]).toContain(active.identity.username!) + expect(permissionRequests[0]).not.toContain("user-43") + expect(permissionRequests[0]).not.toContain("user-44") + }) + + it("uses each result confirmation time so a later reconciliation denial beats sign-in", async () => { + const { prisma, service, store } = await database() + const users = [] + for (let index = 0; index < 11; index += 1) { + users.push(await createUser(store, { subject: String(100 + index) })) + } + users.sort((left, right) => left.identity.id.localeCompare(right.identity.id)) + const target = users.at(-1)! + await prisma.installationMembership.update({ + where: { id: target.membership.id }, + data: { source: "synchronized" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("later-denial-state"), + browserDigest: digestSecret("later-denial-browser"), + pkceVerifier: "later-denial-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const subjectByUsername = new Map( + users.map(({ identity }) => [identity.username!, identity.subject]), + ) + const firstPermissionBatch: Array<{ + subject: string + response: ReturnType> + }> = [] + let targetPermissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const username = decodeURIComponent( + url.match(/collaborators\/([^/]+)\/permission$/)?.[1] ?? "", + ) + const subject = subjectByUsername.get(username) + if (!subject) throw new Error(`Unexpected request: ${url}`) + if (username === target.identity.username) { + targetPermissionRequests += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: Number(subject) }, + permission: targetPermissionRequests === 1 ? "write" : "none", + }), + { status: 200 }, + ), + ) + } + const response = deferred() + firstPermissionBatch.push({ subject, response }) + return response.promise + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => expect(firstPermissionBatch).toHaveLength(10)) + const laterSignIn = await authentication.complete( + "code", + "later-denial-state", + "later-denial-browser", + ) + expect(laterSignIn.purpose).toBe("sign-in") + expect(typeof laterSignIn.sessionToken).toBe("string") + firstPermissionBatch.forEach(({ subject, response }) => + response.resolve( + new Response(JSON.stringify({ user: { id: Number(subject) }, permission: "read" }), { + status: 200, + }), + ), + ) + await reconciliation + + expect(targetPermissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: target.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + + it("applies confirmed identities in bounded transactions outside the identity lock", async () => { + const { prisma, store } = await database() + for (let index = 0; index < 51; index += 1) { + await createUser(store, { subject: String(1_000 + index) }) + } + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const transactionState = new AsyncLocalStorage<{ baselineWrites: number }>() + let identityLockDepth = 0 + let baselineWritesInsideIdentityLock = 0 + const observedDatabase = prisma.$extends({ + query: { + gitHubSynchronizedAccess: { + upsert({ args, query }) { + const state = transactionState.getStore() + if (state) state.baselineWrites += 1 + if (identityLockDepth > 0) baselineWritesInsideIdentityLock += 1 + return query(args) + }, + }, + }, + }) as unknown as PrismaClient + const originalTransaction = observedDatabase.$transaction.bind(observedDatabase) as unknown as ( + operation: (transaction: Prisma.TransactionClient) => Promise, + ) => Promise + const batchWriteCounts: number[] = [] + vi.spyOn(observedDatabase, "$transaction").mockImplementation( + async (operation: (transaction: Prisma.TransactionClient) => Promise) => { + const state = { baselineWrites: 0 } + const result = await originalTransaction((transaction) => + transactionState.run(state, () => operation(transaction)), + ) + if (state.baselineWrites > 0) batchWriteCounts.push(state.baselineWrites) + return result + }, + ) + const observedStore = createIdentityStore(observedDatabase) + const originalLock = observedStore.lock.bind(observedStore) + vi.spyOn(observedStore, "lock").mockImplementation((operation) => + originalLock(async (...args) => { + identityLockDepth += 1 + try { + return await operation(...args) + } finally { + identityLockDepth -= 1 + } + }), + ) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase, + environment: environment(), + store: observedStore, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const subject = url.match(/collaborators\/user-(\d+)\/permission$/)?.[1] + if (!subject) throw new Error(`Unexpected request: ${url}`) + return Promise.resolve( + new Response(JSON.stringify({ user: { id: Number(subject) }, permission: "read" }), { + status: 200, + }), + ) + }), + ) + + await observedService.reconcileDue() + + expect(batchWriteCounts).toEqual([50, 1]) + expect(baselineWritesInsideIdentityLock).toBe(0) + expect( + await prisma.gitHubSynchronizedAccess.count({ where: { repositorySourceId: source.id } }), + ).toBe(51) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "current", + }) + }) + + it("logs a returned partial reconciliation status without requiring a thrown error", async () => { + const { prisma, service, store } = await database() + await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const errorLog = vi.spyOn(logger, "error").mockImplementation(() => undefined) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect(errorLog).toHaveBeenCalledWith( + { + repositorySourceId: source.id, + reason: "permission_partial_failure", + }, + "GitHub authorization reconciliation failed", + ) + }) + + it("coalesces repeated dirty webhooks into one bounded reconciliation retry", async () => { + const { prisma, service } = await database() + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + }, + }) + const repositoryRequested = deferred() + const fetchMock = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequested.resolve() + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", fetchMock) + for (const deliveryId of ["dirty-one", "dirty-two", "dirty-three"]) { + await expect( + service.processWebhook( + webhook({ action: "edited", installation: { id: 7 } }, "membership", deliveryId), + ), + ).resolves.toBe("accepted") + } + + expect(fetchMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(999) + expect(fetchMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await repositoryRequested.promise + + expect( + fetchMock.mock.calls.filter(([input]) => requestUrl(input).endsWith("/access_tokens")), + ).toHaveLength(1) + expect( + fetchMock.mock.calls.filter(([input]) => requestUrl(input).endsWith("/repositories/100")), + ).toHaveLength(1) + await vi.waitFor(async () => { + await expect( + prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).resolves.toMatchObject({ synchronizationStatus: "current" }) + }) + }) + it("keeps the synchronized authorization migration constraints and compatibility backfill", () => { const migration = readFileSync( new URL( diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 0e3ca204..dee52692 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -190,7 +190,6 @@ async function saveBaseline( skipOlderConfirmation = false, requireAdmissionAttempt = true, ): Promise<"saved" | "superseded"> { - if (!baseline.persist) return "saved" await lockRepository(database, baseline.repositorySourceId) const source = await database.gitHubRepositorySource.findUnique({ where: { id: baseline.repositorySourceId }, @@ -212,6 +211,32 @@ async function saveBaseline( "GitHub authorization changed. Start again.", ) } + if (!baseline.persist) { + const cutoff = new Date(Date.now() - (await lastKnownGoodSeconds(database)) * 1_000) + const cached = await database.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + }) + if ( + !cached || + cached.status !== "active" || + !cached.admissionEligible || + cached.rawPermission !== baseline.rawPermission || + cached.lastConfirmedAt.getTime() !== baseline.confirmedAt.getTime() || + cached.lastConfirmedAt < cutoff + ) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + return "saved" + } if (requireAdmissionAttempt) { const attempt = await database.gitHubAdmissionAttempt.findUnique({ where: { @@ -449,11 +474,25 @@ export function createGitHubAuthorizationService({ throw new Error("HUB_GITHUB_WEBHOOK_SECRET must contain between 20 and 1024 characters.") } let reconciliationDueRunning = false + const dirtyRetrySources = new Set() + let dirtyRetryTimer: NodeJS.Timeout | null = null async function sourceCredentials() { return credentials(store, createGitHubIntegrationCredentialProvider(environment)) } + function scheduleDirtyReconciliation(sourceIds: string[]) { + for (const sourceId of sourceIds) dirtyRetrySources.add(sourceId) + if (dirtyRetryTimer || dirtyRetrySources.size === 0) return + dirtyRetryTimer = setTimeout(() => { + const sourceIds = [...dirtyRetrySources] + dirtyRetrySources.clear() + dirtyRetryTimer = null + void Promise.allSettled(sourceIds.map((sourceId) => reconcile(sourceId))) + }, 1_000) + dirtyRetryTimer.unref() + } + async function claimAdmissionGeneration( repositorySourceId: string, subject: string, @@ -492,6 +531,7 @@ export function createGitHubAuthorizationService({ "The repository link was not found.", ) } + const attemptedAt = new Date() return transaction.gitHubRepositorySource.update({ where: { id: current.id }, @@ -528,7 +568,12 @@ export function createGitHubAuthorizationService({ let identityCursor: string | undefined while (true) { const page = await database.externalIdentity.findMany({ - where: { provider: "github", issuer: "https://github.com", username: { not: null } }, + where: { + provider: "github", + issuer: "https://github.com", + username: { not: null }, + user: { memberships: { some: { status: "active" } } }, + }, select: { id: true, userId: true, subject: true, username: true }, orderBy: { id: "asc" }, ...(identityCursor ? { cursor: { id: identityCursor }, skip: 1 } : {}), @@ -538,7 +583,6 @@ export function createGitHubAuthorizationService({ if (page.length < 100) break identityCursor = page.at(-1)?.id } - const confirmedAt = new Date() const results = await mapWithConcurrency(identities, 10, async (identity) => { const claim = await claimAdmissionGeneration( source.id, @@ -561,6 +605,7 @@ export function createGitHubAuthorizationService({ subject: identity.subject, admissionGeneration: claim.admissionGeneration, rawPermission, + confirmedAt: new Date(), ...mapGitHubPermission(rawPermission), } } catch { @@ -568,7 +613,58 @@ export function createGitHubAuthorizationService({ } }) const partialFailure = results.some((result) => result?.status === "failed") + const confirmedAt = results.reduce( + (latest, result) => + result?.status === "confirmed" && + (!latest || result.confirmedAt.getTime() > latest.getTime()) + ? result.confirmedAt + : latest, + null, + ) phase = "apply" + const actor = actorSessionToken + ? await currentAdministrator(store, actorSessionToken, undefined, false) + : null + const confirmedResults = results.filter( + (result): result is NonNullable & { status: "confirmed" } => + result?.status === "confirmed", + ) + for (let index = 0; index < confirmedResults.length; index += 50) { + const batch = confirmedResults.slice(index, index + 50) + await database.$transaction(async (transaction) => { + await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return + } + for (const result of batch) { + await saveBaseline( + transaction, + result.userId, + { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + subject: result.subject, + admissionGeneration: result.admissionGeneration, + persist: true, + rawPermission: result.rawPermission, + admissionEligible: result.admissionEligible, + prototypeRole: result.prototypeRole, + confirmedAt: result.confirmedAt, + }, + false, + true, + true, + ) + } + }) + } const applied = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ @@ -581,9 +677,9 @@ export function createGitHubAuthorizationService({ ) { return false } - const actor = actorSessionToken - ? await currentAdministrator(store, actorSessionToken, transaction, false) - : null + if (actorSessionToken) { + await currentAdministrator(store, actorSessionToken, transaction, false) + } await transaction.gitHubRepositorySource.update({ where: { id: source.id }, data: { @@ -596,31 +692,10 @@ export function createGitHubAuthorizationService({ synchronizationStatus: partialFailure ? "failed" : "current", synchronizationError: partialFailure ? "permission_partial_failure" : null, dirtyAt: null, - ...(partialFailure ? {} : { lastConfirmedAt: confirmedAt }), + ...(!partialFailure && confirmedAt ? { lastConfirmedAt: confirmedAt } : {}), }, }) - for (const result of results) { - if (!result || result.status !== "confirmed") continue - await saveBaseline( - transaction, - result.userId, - { - repositorySourceId: source.id, - sourceGeneration: source.synchronizationGeneration, - subject: result.subject, - admissionGeneration: result.admissionGeneration, - persist: true, - rawPermission: result.rawPermission, - admissionEligible: result.admissionEligible, - prototypeRole: result.prototypeRole, - confirmedAt, - }, - false, - true, - true, - ) - } - await enforceSynchronizedMemberships(transaction, confirmedAt) + await enforceSynchronizedMemberships(transaction, confirmedAt ?? new Date()) await saveSecurityAudit(transaction, { event: "github.repository.reconcile", actorUserId: actor?.userId ?? null, @@ -741,12 +816,46 @@ export function createGitHubAuthorizationService({ ) const membership = identity?.user.memberships[0] let integration: GitHubCredentials - let token: string try { integration = await sourceCredentials() + } catch (error) { + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + throw error instanceof AuthenticationError + ? error + : new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + let token: string + try { token = await createInstallationToken(integration) } catch (error) { - if (error instanceof GitHubApiError) { + if (error instanceof GitHubApiError && error.status === 404) { + await store.lock(async (_records, transaction) => { + for (const source of sources) { + if ( + source.appId !== integration.appId || + source.installationId !== integration.installationId + ) { + continue + } + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + current?.status === "active" && + current.synchronizationGeneration === source.synchronizationGeneration + ) { + await removeRepositorySource(transaction, source.id, new Date(), false) + } + } + await enforceSynchronizedMemberships(transaction, new Date()) + }) + } else if (error instanceof GitHubApiError) { for (const source of sources) { if ( source.appId !== integration!.appId || @@ -1166,7 +1275,13 @@ export function createGitHubAuthorizationService({ }) for (const source of sources) { try { - await reconcile(source.id) + const reconciled = await reconcile(source.id) + if (reconciled?.synchronizationStatus === "failed") { + log("error", "GitHub authorization reconciliation failed", { + repositorySourceId: source.id, + reason: reconciled.synchronizationError ?? "reconciliation_failed", + }) + } } catch { const failed = await database.gitHubRepositorySource.findUnique({ where: { id: source.id }, @@ -1241,10 +1356,16 @@ export function createGitHubAuthorizationService({ "The GitHub webhook is invalid.", ) } - const integration = await sourceCredentials() - if (String(payload.data.installation.id) !== integration.installationId) return "ignored" + const installationId = String(payload.data.installation.id) try { - return await store.lock(async (_records, transaction) => { + const processed = await store.lock(async (_records, transaction) => { + const linkedInstallation = await transaction.gitHubRepositorySource.findFirst({ + where: { installationId }, + select: { id: true }, + }) + if (!linkedInstallation) { + return { result: "ignored" as const, dirtySourceIds: [] } + } const repositoryId = payload.data.repository?.id const source = repositoryId ? await transaction.gitHubRepositorySource.findUnique({ @@ -1256,7 +1377,7 @@ export function createGitHubAuthorizationService({ deliveryId, event, action: payload.data.action ?? null, - installationId: integration.installationId, + installationId, repositorySourceId: source?.id ?? null, }, }) @@ -1264,7 +1385,7 @@ export function createGitHubAuthorizationService({ if (event === "installation" && payload.data.action === "deleted") { const sources = await transaction.gitHubRepositorySource.findMany({ where: { - installationId: integration.installationId, + installationId, status: { in: ["active", "suspended"] }, }, select: { id: true }, @@ -1274,9 +1395,9 @@ export function createGitHubAuthorizationService({ } await enforceSynchronizedMemberships(transaction, now) } else if (event === "installation" && payload.data.action === "suspend") { - await suspendInstallationSources(transaction, integration.installationId, now) + await suspendInstallationSources(transaction, installationId, now) } else if (event === "installation" && payload.data.action === "unsuspend") { - await resumeInstallationSources(transaction, integration.installationId, now) + await resumeInstallationSources(transaction, installationId, now) } else { const dirtyIds = new Set() const removedIds = new Set() @@ -1312,7 +1433,7 @@ export function createGitHubAuthorizationService({ }) } else if (removedIds.size === 0) { await transaction.gitHubRepositorySource.updateMany({ - where: { installationId: integration.installationId, status: "active" }, + where: { installationId, status: "active" }, data: { synchronizationGeneration: { increment: 1 }, synchronizationStatus: "dirty", @@ -1329,8 +1450,21 @@ export function createGitHubAuthorizationService({ githubRepositorySourceId: source?.id ?? null, context: { source: "github_webhook" }, }) - return "accepted" as const + const dirtySources = await transaction.gitHubRepositorySource.findMany({ + where: { + installationId, + status: "active", + synchronizationStatus: "dirty", + }, + select: { id: true }, + }) + return { + result: "accepted" as const, + dirtySourceIds: dirtySources.map(({ id }) => id), + } }) + scheduleDirtyReconciliation(processed.dirtySourceIds) + return processed.result } catch (error) { if ( error instanceof Error && diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index dd1522c4..fdf09ebe 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -240,7 +240,15 @@ describe("createPrototypeAuthorizationService() database behavior", () => { "authorization-administrator-token", workspace.id, { name: "Prototype" }, - )) as { id: string } + )) as { + id: string + authorizationMode: string + sourceRepositoryId: string | null + } + expect(prototype).toMatchObject({ + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -304,6 +312,23 @@ describe("createPrototypeAuthorizationService() database behavior", () => { authorizationMode: "github-synchronized", sourceRepositoryId: source.id, }) + await expect( + service.getPrototype("authorization-administrator-token", prototype.id), + ).resolves.toMatchObject({ + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }) + await expect( + service.listPrototypes("authorization-administrator-token", workspace.id, {}), + ).resolves.toMatchObject({ + items: [ + expect.objectContaining({ + id: prototype.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }), + ], + }) expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( { authorizationMode: "github-synchronized", @@ -1975,10 +2000,12 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(Object.keys(page.items[0] ?? {}).sort()).toEqual( [ "archivedAt", + "authorizationMode", "createdAt", "createdByUserId", "id", "name", + "sourceRepositoryId", "updatedAt", "workspaceId", ].sort(), diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index a43a801e..8f116720 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -353,6 +353,8 @@ function prototypeDto(prototype: { workspaceId: string createdByUserId: string name: string + authorizationMode: string + sourceRepositoryId: string | null archivedAt: Date | null createdAt: Date updatedAt: Date @@ -362,6 +364,8 @@ function prototypeDto(prototype: { workspaceId: prototype.workspaceId, createdByUserId: prototype.createdByUserId, name: prototype.name, + authorizationMode: prototype.authorizationMode, + sourceRepositoryId: prototype.sourceRepositoryId, archivedAt: prototype.archivedAt, createdAt: prototype.createdAt, updatedAt: prototype.updatedAt, @@ -864,6 +868,8 @@ export function createPrototypeAuthorizationService({ workspaceId: prototype.workspaceId, createdByUserId: prototype.createdByUserId, name: prototype.name, + authorizationMode: prototype.authorizationMode, + sourceRepositoryId: prototype.sourceRepositoryId, archivedAt: prototype.archivedAt, createdAt: prototype.createdAt, updatedAt: prototype.updatedAt, From b83a590c1078e45fbec53e23c1cbf1c78765b11f Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 09:43:16 -0700 Subject: [PATCH 10/12] fix(hub): harden synchronized source events Refresh immutable logins, revoke installations safely, scope webhook mutations, coalesce retries, and audit batched reconciliation updates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/github-integration.md | 2 +- .../create-service/index.test.ts | 2 +- .../get-current-session/index.ts | 3 +- .../create-service/index.db.test.ts | 934 ++++++++++++++++-- .../create-service/index.ts | 151 ++- .../identity/create-store/index.db.test.ts | 1 + .../domains/identity/create-store/index.ts | 1 + .../domains/identity/create-store/types.ts | 1 + .../check-repository-admission/constants.ts | 5 + .../check-repository-admission/index.test.ts | 26 +- 10 files changed, 1013 insertions(+), 113 deletions(-) diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md index 686360b8..563d76b9 100644 --- a/hub/docs/github-integration.md +++ b/hub/docs/github-integration.md @@ -33,7 +33,7 @@ GitHub does not map to Hub installation administrator or prototype administrator Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. These per-user claims use the repository row lock without taking the global identity-store lock. Admission claims before resolving mutable repository metadata and returns a retryable conflict when the source generation changed, so a rename or transfer cannot substitute a replacement repository or appear as permission loss. -`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Revocation events match the signed installation ID against trusted linked-source records and do not require usable API credentials, so credential loss cannot block deletion or suspension. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Dirty events are coalesced into a bounded near-term retry so a scan superseded by frequent organization changes does not wait for the next 15-minute tick. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Revocation events match the signed installation ID against trusted linked-source records and do not require usable API credentials, so credential loss cannot block deletion or suspension. Configure the GitHub App to send installation, installation-repository, repository, member, membership, organization, team, and team-add events. Permission-only events mark affected sources dirty without invalidating a healthy in-flight scan; repository identity and installation-repository changes retain source-generation invalidation. Dirty events are coalesced into bounded near-term retry batches, and the retry guard remains active until each batch finishes. ## Staleness and outages diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index bd672116..36bf2064 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -709,7 +709,7 @@ describe("createAuthenticationService()", () => { userId: "user", status: "active", role: "member", - source: "synchronized", + source: "legacy-synchronized", suspendedAt: null, removedAt: null, }) diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts index d023f7ae..68baed30 100644 --- a/hub/server/domains/authentication/get-current-session/index.ts +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -6,7 +6,8 @@ import { digestSecret } from "#server/utils/digest-secret" export async function getCurrentSession( dependencies: AuthenticationDependencies, sessionToken: string, - records: Pick = dependencies.store.read, + records: Pick = + dependencies.store.read, ) { const session = sessionToken ? await records.session(digestSecret(sessionToken)) : null if (!session) { diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index bffa8315..42e664e6 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -135,6 +135,15 @@ function requestUrl(input: string | URL | Request): string { return typeof input === "string" ? input : input instanceof URL ? input.href : input.url } +function currentGitHubUserResponse(url: string): Response | null { + const subject = url.match(/\/user\/(\d+)$/)?.[1] + return subject + ? new Response(JSON.stringify({ id: Number(subject), login: `user-${subject}` }), { + status: 200, + }) + : null +} + async function saveAdmissionFixture( prisma: PrismaClient, service: GitHubAuthorizationService, @@ -391,6 +400,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { default_branch: "trunk", visibility: "private", }, + { id: 42, login: "user-42" }, { user: { id: 42 }, permission: "none" }, ] const request = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( @@ -403,6 +413,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(request.mock.calls.map(([url]) => url)).toEqual([ "https://api.github.com/app/installations/7/access_tokens", "https://api.github.com/repositories/100", + "https://api.github.com/user/42", "https://api.github.com/repos/new-owner/new-name/collaborators/user-42/permission", ]) expect(new Headers(request.mock.calls[1]?.[1]?.headers).get("authorization")).toBe( @@ -437,6 +448,100 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("resolves the current GitHub login from the immutable subject before reconciling permission", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-session", + }) + await prisma.externalIdentity.update({ + where: { id: actor.identity.id }, + data: { username: "stale-login" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const request = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.endsWith("/user/42")) { + return Promise.resolve( + new Response(JSON.stringify({ id: 42, login: "current-login" }), { status: 200 }), + ) + } + if (url.endsWith("/collaborators/current-login/permission")) { + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { + status: 200, + }), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/user/42", + "https://api.github.com/repos/owner/repository/collaborators/current-login/permission", + ]) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "active" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).not.toBeNull() + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ rawPermission: "read", admissionEligible: true, status: "active" }) + }) + it("does not resurrect a repository removed while reconciliation is waiting on GitHub", async () => { const { prisma, service, store } = await database() const actor = await createUser(store, { subject: "42" }) @@ -601,7 +706,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { await service.processWebhook( webhook( { - action: "added", + action: "renamed", installation: { id: 7 }, repository: { id: 100, @@ -611,7 +716,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { visibility: "private", }, }, - "member", + "repository", "source-generation-changed-before-claim", ), ) @@ -723,6 +828,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { repositoryRequested.resolve() return repositoryResponse.promise } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) return Promise.resolve( new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), ) @@ -807,6 +914,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) if (url.includes("user-42")) { permissionRequested.resolve() return permissionResponse.promise @@ -910,6 +1019,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) if (url.includes("/repos/owner/repository-a/")) { return Promise.resolve( new Response( @@ -1285,8 +1396,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) if ( - url.endsWith("/repos/renamed-owner/renamed-repository/collaborators/octocat/permission") + url.endsWith("/repos/renamed-owner/renamed-repository/collaborators/user-42/permission") ) { return Promise.resolve( new Response( @@ -1323,7 +1436,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ "https://api.github.com/app/installations/7/access_tokens", "https://api.github.com/repositories/100", - "https://api.github.com/repos/renamed-owner/renamed-repository/collaborators/octocat/permission", + "https://api.github.com/user/42", + "https://api.github.com/repos/renamed-owner/renamed-repository/collaborators/user-42/permission", ]) }, ) @@ -1372,10 +1486,12 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } - if (url.endsWith("/repos/owner/repository/collaborators/octocat")) { + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + if (url.endsWith("/repos/owner/repository/collaborators/user-42")) { return Promise.resolve(new Response(null, { status: collaboratorStatus })) } - if (url.endsWith("/repos/owner/repository/collaborators/octocat/permission")) { + if (url.endsWith("/repos/owner/repository/collaborators/user-42/permission")) { return Promise.resolve( new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200, @@ -1409,9 +1525,10 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ "https://api.github.com/app/installations/7/access_tokens", "https://api.github.com/repositories/100", - "https://api.github.com/repos/owner/repository/collaborators/octocat", + "https://api.github.com/user/42", + "https://api.github.com/repos/owner/repository/collaborators/user-42", ...(admitted - ? ["https://api.github.com/repos/owner/repository/collaborators/octocat/permission"] + ? ["https://api.github.com/repos/owner/repository/collaborators/user-42/permission"] : []), ]) }, @@ -1601,10 +1718,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) it("records a malformed provider response separately from provider availability", async () => { - const { prisma, service } = await database() - await prisma.gitHubIntegrationState.create({ - data: { id: "00000000-0000-4000-8000-000000000001" }, - }) + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) const source = await prisma.gitHubRepositorySource.create({ data: { githubRepositoryId: "100", @@ -1617,16 +1732,22 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) vi.stubGlobal( "fetch", - vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ id: 100, full_name: "owner/repository" }), { - status: 200, - }), - ), + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response(JSON.stringify({ id: 100, full_name: "owner/repository" }), { + status: 200, + }), + ) + } + throw new Error(`Unexpected request: ${url}`) + }), ) await expect(service.reconcileDue()).resolves.toBeUndefined() @@ -1701,6 +1822,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) return Promise.resolve( new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { status: 200 }), ) @@ -1955,6 +2078,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) const subject = url.match(/collaborators\/user-(\d+)\/permission$/)?.[1] if (!subject) throw new Error(`Unexpected request: ${url}`) permissionRequestCount += 1 @@ -2071,6 +2196,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) if (url.includes("user-42")) { return Promise.resolve( new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { @@ -2141,7 +2268,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - const provider = await store.read.currentAuthenticationProviderRevision("github") + const provider = await store.lock((records) => + records.currentAuthenticationProviderRevision("github"), + ) await store.lock((records) => records.createAuthorizationAttempt({ authenticationProviderRevisionId: provider!.id, @@ -2178,6 +2307,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) return Promise.resolve( new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), ) @@ -2252,7 +2383,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - const provider = await store.read.currentAuthenticationProviderRevision("github") + const provider = await store.lock((records) => + records.currentAuthenticationProviderRevision("github"), + ) await store.lock(async (records) => { for (const suffix of ["older", "newer"]) { await records.createAuthorizationAttempt({ @@ -2294,6 +2427,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) permissionRequests += 1 if (permissionRequests === 1) { olderPermissionRequested.resolve() @@ -2381,7 +2516,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - const provider = await store.read.currentAuthenticationProviderRevision("github") + const provider = await store.lock((records) => + records.currentAuthenticationProviderRevision("github"), + ) await store.lock((records) => records.createAuthorizationAttempt({ authenticationProviderRevisionId: provider!.id, @@ -2421,6 +2558,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) permissionRequests += 1 if (permissionRequests === 1) { signInPermissionRequested.resolve() @@ -2546,6 +2685,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) if (url.includes("user-42")) { targetPermissionRequested.resolve() return targetPermission.promise @@ -2632,6 +2773,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) if (url.includes("user-42")) { targetPermissionRequests += 1 if (targetPermissionRequests === 1) { @@ -2653,7 +2796,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { const reconciliation = service.reconcileDue() await targetPermissionRequested.promise const now = new Date() - const provider = await store.read.currentAuthenticationProviderRevision("github") + const provider = await store.lock((records) => + records.currentAuthenticationProviderRevision("github"), + ) await store.lock((records) => records.createAuthorizationAttempt({ authenticationProviderRevisionId: provider!.id, @@ -2773,64 +2918,296 @@ describe("createGitHubAuthorizationService() database behavior", () => { it.each([ { - name: "installation deletion", - event: "installation", - payload: { action: "deleted", installation: { id: 7 } }, + name: "repository removal", + event: "repository", + payload: { + action: "deleted", + installation: { id: 7 }, + repository: { + id: 100, + name: "relinked", + owner: { login: "owner" }, + default_branch: "main", + }, + }, }, { - name: "repository removal", - event: "installation_repositories", + name: "permission change", + event: "member", payload: { - action: "removed", + action: "edited", installation: { id: 7 }, - repositories_removed: [{ id: 100 }], + repository: { + id: 100, + name: "relinked", + owner: { login: "owner" }, + default_branch: "main", + }, }, }, - ])("removes suspended sources after $name", async ({ event, payload }) => { - const { prisma, service, store } = await database() - await store.lock(() => Promise.resolve()) - const source = await prisma.gitHubRepositorySource.create({ - data: { - githubRepositoryId: "100", - appId: "12345", - installationId: "7", - owner: "owner", - name: "repository", - defaultBranch: "main", + ])( + "does not apply a delayed old-installation $name to a relinked repository", + async ({ event, payload }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "999", + appId: "12345", + installationId: "7", + owner: "owner", + name: "retained-old-installation-source", + defaultBranch: "main", + }, + }) + const relinked = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "8", + owner: "owner", + name: "relinked", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + }, + }) + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + + await expect( + service.processWebhook(webhook(payload, event, `old-installation-${event}`)), + ).resolves.toBe("accepted") + await vi.advanceTimersByTimeAsync(1_000) + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: relinked.id } }), + ).toMatchObject({ + status: "active", admissionEnabled: true, - status: "suspended", - synchronizationStatus: "removed", - }, - }) + synchronizationStatus: "current", + synchronizationGeneration: relinked.synchronizationGeneration, + dirtyAt: null, + }) + expect( + fetchMock.mock.calls.some(([input]) => + requestUrl(input).endsWith(`/repositories/${relinked.githubRepositoryId}`), + ), + ).toBe(false) + }, + ) - await expect( - service.processWebhook(webhook(payload, event, `suspended-${event}-removal`)), - ).resolves.toBe("accepted") - await expect( - service.processWebhook( - webhook( - { action: "unsuspend", installation: { id: 7 } }, - "installation", - `suspended-${event}-later-unsuspend`, + it.each(["team", "organization"] as const)( + "accepts a signed %s event, marks the installation dirty, and schedules reconciliation", + async (event) => { + const { prisma, service } = await database() + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + }, + }) + const request = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + await expect( + service.processWebhook( + webhook({ action: "edited", installation: { id: 7 } }, event, `${event}-dirty`), ), - ), - ).resolves.toBe("accepted") + ).resolves.toBe("accepted") + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "dirty", + synchronizationGeneration: source.synchronizationGeneration, + }) - expect( - await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), - ).toMatchObject({ - status: "removed", - admissionEnabled: false, - synchronizationStatus: "removed", - }) - }) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => + expect( + request.mock.calls.filter(([input]) => + requestUrl(input).endsWith("/repositories/100"), + ), + ).toHaveLength(1), + ) + }, + ) - it("enforces synchronized memberships once after removing multiple sources", async () => { - const { prisma, store } = await database() - const actor = await createUser(store, { - subject: "42", - membershipSource: "synchronized", - token: "synchronized-token", + it.each([ + { + event: "repository", + payload: { + action: "renamed", + installation: { id: 7 }, + repository: { + id: 100, + name: "renamed", + owner: { login: "owner" }, + default_branch: "main", + }, + }, + }, + { + event: "installation_repositories", + payload: { + action: "added", + installation: { id: 7 }, + repositories_added: [{ id: 100 }], + }, + }, + ])( + "advances source generation and schedules reconciliation for an identity-changing $event event", + async ({ event, payload }) => { + const { prisma, service } = await database() + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + }, + }) + const request = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + await expect( + service.processWebhook(webhook(payload, event, `identity-change-${event}`)), + ).resolves.toBe("accepted") + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "dirty", + synchronizationGeneration: source.synchronizationGeneration + 1, + }) + + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => + expect( + request.mock.calls.filter(([input]) => + requestUrl(input).endsWith("/repositories/100"), + ), + ).toHaveLength(1), + ) + }, + ) + + it.each([ + { + name: "installation deletion", + event: "installation", + payload: { action: "deleted", installation: { id: 7 } }, + }, + { + name: "repository removal", + event: "installation_repositories", + payload: { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + }, + ])("removes suspended sources after $name", async ({ event, payload }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + status: "suspended", + synchronizationStatus: "removed", + }, + }) + + await expect( + service.processWebhook(webhook(payload, event, `suspended-${event}-removal`)), + ).resolves.toBe("accepted") + await expect( + service.processWebhook( + webhook( + { action: "unsuspend", installation: { id: 7 } }, + "installation", + `suspended-${event}-later-unsuspend`, + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + }) + + it("enforces synchronized memberships once after removing multiple sources", async () => { + const { prisma, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", }) const sources = await Promise.all( ["100", "200"].map((githubRepositoryId) => @@ -3189,7 +3566,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEligible: true, confirmedAt: new Date(), }) - const provider = await store.read.currentAuthenticationProviderRevision("github") + const provider = await store.lock((records) => + records.currentAuthenticationProviderRevision("github"), + ) await store.lock((records) => records.createAuthorizationAttempt({ authenticationProviderRevisionId: provider!.id, @@ -3340,6 +3719,154 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() }) + it("removes every unchanged source for an installation-token 404, including prototype-only sources", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-session", + }) + const [admissionSource, prototypeSource, otherInstallation] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "admission", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "prototype-only", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "300", + appId: "12345", + installationId: "8", + owner: "owner", + name: "other-installation", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + for (const source of [admissionSource, prototypeSource]) { + const currentSource = await prisma.gitHubRepositorySource.findUniqueOrThrow({ + where: { id: source.id }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: currentSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + } + await prisma.gitHubRepositorySource.update({ + where: { id: prototypeSource.id }, + data: { admissionEnabled: false }, + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 }))) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findMany({ + where: { id: { in: [admissionSource.id, prototypeSource.id] } }, + orderBy: { id: "asc" }, + select: { status: true, admissionEnabled: true }, + }), + ).toEqual([ + { status: "removed", admissionEnabled: false }, + { status: "removed", admissionEnabled: false }, + ]) + expect( + await prisma.gitHubSynchronizedAccess.findMany({ + where: { repositorySourceId: { in: [admissionSource.id, prototypeSource.id] } }, + select: { status: true }, + }), + ).toEqual([{ status: "removed" }, { status: "removed" }]) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ + where: { id: otherInstallation.id }, + }), + ).toMatchObject({ status: "active", admissionEnabled: true }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }) + + it("preserves a newer source when installation-token 404 removal starts from a stale snapshot", async () => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const tokenRequested = deferred() + const tokenResponse = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (!url.endsWith("/access_tokens")) throw new Error(`Unexpected request: ${url}`) + tokenRequested.resolve() + return tokenResponse.promise + }), + ) + const admission = service.evaluateAdmission({ + subject: "42", + username: "user-42", + displayName: "User 42", + avatarUrl: null, + }) + await tokenRequested.promise + await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { + installationId: "8", + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + }, + }) + tokenResponse.resolve(new Response(null, { status: 404 })) + await expect(admission).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + status: "active", + installationId: "8", + admissionEnabled: true, + synchronizationGeneration: source.synchronizationGeneration + 1, + }) + }) + it.each([ { action: "deleted", expectedSourceStatus: "removed" }, { action: "suspend", expectedSourceStatus: "suspended" }, @@ -3450,6 +3977,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) permissionRequests.push(url) return Promise.resolve( new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), @@ -3488,7 +4017,9 @@ describe("createGitHubAuthorizationService() database behavior", () => { admissionEnabled: true, }, }) - const provider = await store.read.currentAuthenticationProviderRevision("github") + const provider = await store.lock((records) => + records.currentAuthenticationProviderRevision("github"), + ) await store.lock((records) => records.createAuthorizationAttempt({ authenticationProviderRevisionId: provider!.id, @@ -3533,6 +4064,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) const username = decodeURIComponent( url.match(/collaborators\/([^/]+)\/permission$/)?.[1] ?? "", ) @@ -3707,6 +4240,8 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) const subject = url.match(/collaborators\/user-(\d+)\/permission$/)?.[1] if (!subject) throw new Error(`Unexpected request: ${url}`) return Promise.resolve( @@ -3724,6 +4259,14 @@ describe("createGitHubAuthorizationService() database behavior", () => { expect( await prisma.gitHubSynchronizedAccess.count({ where: { repositorySourceId: source.id } }), ).toBe(51) + expect( + await prisma.securityAudit.count({ + where: { + event: "github.repository.reconcile.batch", + githubRepositorySourceId: source.id, + }, + }), + ).toBe(2) expect( await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), ).toMatchObject({ @@ -3732,6 +4275,105 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("rolls back a reconciliation baseline batch when its required audit cannot be saved", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_reconciliation_batch_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = 'github.repository.reconcile.batch' THEN + RAISE EXCEPTION 'synthetic reconciliation batch audit failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_reconciliation_batch_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_reconciliation_batch_audit(); + `) + onTestFinished(async () => { + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS reject_reconciliation_batch_audit ON security_audits", + ) + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS reject_reconciliation_batch_audit()", + ) + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.endsWith("/user/42")) { + return Promise.resolve( + new Response(JSON.stringify({ id: 42, login: "user-42" }), { status: 200 }), + ) + } + if (url.endsWith("/collaborators/user-42/permission")) { + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { + status: 200, + }), + ) + } + throw new Error(`Unexpected request: ${url}`) + }), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubSynchronizedAccess.count({ + where: { repositorySourceId: source.id, userId: actor.user.id }, + }), + ).toBe(0) + expect( + await prisma.securityAudit.count({ + where: { + event: "github.repository.reconcile.batch", + githubRepositorySourceId: source.id, + }, + }), + ).toBe(0) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "apply_failed", + }) + }) + it("logs a returned partial reconciliation status without requiring a thrown error", async () => { const { prisma, service, store } = await database() await createUser(store, { subject: "42" }) @@ -3853,6 +4495,148 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) }) + it("keeps the dirty retry guard through a bounded batch, retains new IDs, and logs failures", async () => { + const { prisma, service } = await database() + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + const initialSources = await Promise.all( + ["100", "200", "300", "400"].map((githubRepositoryId) => + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId, + appId: "12345", + installationId: "7", + owner: "owner", + name: `repository-${githubRepositoryId}`, + defaultBranch: "main", + synchronizationStatus: "current", + }, + }), + ), + ) + const initialRepositoryRequests: Array<{ + githubRepositoryId: string + response: ReturnType> + }> = [] + const initialBatchStarted = deferred() + const requestedRepositoryIds: string[] = [] + let firstBatchPending = true + let concurrentRepositoryRequests = 0 + let maximumConcurrentRepositoryRequests = 0 + const request = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + const githubRepositoryId = url.match(/\/repositories\/(\d+)$/)?.[1] + if (!githubRepositoryId) throw new Error(`Unexpected request: ${url}`) + requestedRepositoryIds.push(githubRepositoryId) + concurrentRepositoryRequests += 1 + maximumConcurrentRepositoryRequests = Math.max( + maximumConcurrentRepositoryRequests, + concurrentRepositoryRequests, + ) + if (firstBatchPending) { + const response = deferred() + initialRepositoryRequests.push({ githubRepositoryId, response }) + if (initialRepositoryRequests.length === 4) initialBatchStarted.resolve() + return response.promise.finally(() => { + concurrentRepositoryRequests -= 1 + }) + } + concurrentRepositoryRequests -= 1 + return Promise.resolve( + new Response( + JSON.stringify({ + id: Number(githubRepositoryId), + full_name: `owner/repository-${githubRepositoryId}`, + name: `repository-${githubRepositoryId}`, + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + }) + vi.stubGlobal("fetch", request) + const errorLog = vi.spyOn(logger, "error").mockImplementation(() => undefined) + + await service.processWebhook( + webhook({ action: "edited", installation: { id: 7 } }, "membership", "initial-dirty-batch"), + ) + await vi.advanceTimersByTimeAsync(1_000) + await initialBatchStarted.promise + const laterSource = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "500", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository-500", + defaultBranch: "main", + synchronizationStatus: "current", + }, + }) + await service.processWebhook( + webhook( + { + action: "edited", + installation: { id: 7 }, + repository: { + id: 500, + name: "repository-500", + owner: { login: "owner" }, + default_branch: "main", + }, + }, + "member", + "dirty-during-running-batch", + ), + ) + + await vi.advanceTimersByTimeAsync(10_000) + expect(initialRepositoryRequests).toHaveLength(4) + expect(requestedRepositoryIds).not.toContain("500") + + firstBatchPending = false + initialRepositoryRequests.forEach(({ githubRepositoryId, response }, index) => { + response.resolve( + index === 0 + ? new Response(null, { status: 503 }) + : new Response( + JSON.stringify({ + id: Number(githubRepositoryId), + full_name: `owner/repository-${githubRepositoryId}`, + name: `repository-${githubRepositoryId}`, + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + }) + await vi.waitFor(() => + expect(errorLog).toHaveBeenCalledWith( + expect.objectContaining({ reason: "reconciliation_failed" }), + "GitHub authorization dirty retry failed", + ), + ) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(requestedRepositoryIds).toHaveLength(8)) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(requestedRepositoryIds).toContain("500")) + + expect(maximumConcurrentRepositoryRequests).toBe(4) + expect(requestedRepositoryIds).toContain(laterSource.githubRepositoryId) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: laterSource.id } }), + ).toMatchObject({ synchronizationStatus: "current" }) + expect(initialSources).toHaveLength(4) + }) + it("keeps the synchronized authorization migration constraints and compatibility backfill", () => { const migration = readFileSync( new URL( diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index dee52692..610fadd3 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -16,6 +16,7 @@ import type { } from "#server/domains/github-authorization/create-service/types" import { collaboratorPermissionSchema, + githubUserSchema, repositorySchema, } from "#server/integrations/github/check-repository-admission/constants" import { createInstallationToken } from "#server/integrations/github/create-installation-token" @@ -108,7 +109,7 @@ async function credentials( store: IdentityStore, provider: ReturnType, ): Promise { - const value = provider.read(await store.read.githubIntegrationState()) + const value = provider.read(await store.read.githubIntegrationCredentials()) if (!value) { throw new AuthenticationError( 503, @@ -147,6 +148,7 @@ async function permissionFor( if (String(response.user.id) !== subject) { return "none" } + return normalizePermission(response.permission, response.role_name) } catch (error) { if (error instanceof GitHubApiError && error.status === 404) return "none" @@ -154,6 +156,16 @@ async function permissionFor( } } +async function currentUsername(token: string, subject: string): Promise { + try { + const user = githubUserSchema.parse(await requestGitHubApi(`/user/${subject}`, token)) + return String(user.id) === subject ? user.login : null + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return null + throw error + } +} + function normalizePermission( permission: GitHubRepositoryPermission, roleName?: string | null, @@ -411,6 +423,29 @@ async function removeRepositorySource( if (enforceMemberships) await enforceSynchronizedMemberships(database, removedAt) } +async function removeInstallationSources( + database: Prisma.TransactionClient, + snapshots: Array<{ id: string; synchronizationGeneration: number }>, + removedAt: Date, +): Promise { + for (const snapshot of snapshots) { + await lockRepository(database, snapshot.id) + const current = await database.gitHubRepositorySource.findUnique({ + where: { id: snapshot.id }, + select: { status: true, synchronizationGeneration: true }, + }) + if ( + !current || + !["active", "suspended"].includes(current.status) || + current.synchronizationGeneration !== snapshot.synchronizationGeneration + ) { + continue + } + await removeRepositorySource(database, snapshot.id, removedAt, false) + } + await enforceSynchronizedMemberships(database, removedAt) +} + async function suspendInstallationSources( database: Prisma.TransactionClient, installationId: string, @@ -476,6 +511,7 @@ export function createGitHubAuthorizationService({ let reconciliationDueRunning = false const dirtyRetrySources = new Set() let dirtyRetryTimer: NodeJS.Timeout | null = null + let dirtyRetryRunning = false async function sourceCredentials() { return credentials(store, createGitHubIntegrationCredentialProvider(environment)) @@ -483,12 +519,26 @@ export function createGitHubAuthorizationService({ function scheduleDirtyReconciliation(sourceIds: string[]) { for (const sourceId of sourceIds) dirtyRetrySources.add(sourceId) - if (dirtyRetryTimer || dirtyRetrySources.size === 0) return + if (dirtyRetryTimer || dirtyRetryRunning || dirtyRetrySources.size === 0) return dirtyRetryTimer = setTimeout(() => { - const sourceIds = [...dirtyRetrySources] - dirtyRetrySources.clear() dirtyRetryTimer = null - void Promise.allSettled(sourceIds.map((sourceId) => reconcile(sourceId))) + dirtyRetryRunning = true + void (async () => { + const sourceIds = [...dirtyRetrySources].slice(0, 4) + for (const sourceId of sourceIds) dirtyRetrySources.delete(sourceId) + const results = await Promise.allSettled(sourceIds.map((sourceId) => reconcile(sourceId))) + results.forEach((result, index) => { + if (result.status === "rejected") { + log("error", "GitHub authorization dirty retry failed", { + repositorySourceId: sourceIds[index], + reason: "reconciliation_failed", + }) + } + }) + })().finally(() => { + dirtyRetryRunning = false + if (dirtyRetrySources.size > 0) scheduleDirtyReconciliation([]) + }) }, 1_000) dirtyRetryTimer.unref() } @@ -544,6 +594,14 @@ export function createGitHubAuthorizationService({ }, }) }) + const installationSnapshots = await database.gitHubRepositorySource.findMany({ + where: { + appId: source.appId, + installationId: source.installationId, + status: { in: ["active", "suspended"] }, + }, + select: { id: true, synchronizationGeneration: true }, + }) let phase: "credentials" | "token" | "repository" | "permissions" | "apply" = "credentials" try { const integration = await sourceCredentials() @@ -592,9 +650,21 @@ export function createGitHubAuthorizationService({ ) if (claim.status !== "claimed") return null try { + const username = await currentUsername(token, identity.subject) + if (!username) { + return { + status: "confirmed" as const, + userId: identity.userId, + subject: identity.subject, + admissionGeneration: claim.admissionGeneration, + rawPermission: "none" as const, + confirmedAt: new Date(), + ...mapGitHubPermission("none"), + } + } const rawPermission = await permissionFor( token, - identity.username!, + username, identity.subject, repository.full_name, repository.visibility, @@ -663,6 +733,14 @@ export function createGitHubAuthorizationService({ true, ) } + await saveSecurityAudit(transaction, { + event: "github.repository.reconcile.batch", + actorUserId: actor?.userId ?? null, + sessionId: actor?.id ?? null, + authenticationProviderRevisionId: actor?.authenticationProviderRevisionId ?? null, + githubRepositorySourceId: source.id, + context: { source: "github_app_installation" }, + }) }) } const applied = await store.lock(async (_records, transaction) => { @@ -722,7 +800,13 @@ export function createGitHubAuthorizationService({ ) { throw error } - if (error instanceof GitHubApiError && error.status === 404) { + if (error instanceof GitHubApiError && error.status === 404 && phase === "token") { + await store.lock((_records, transaction) => + removeInstallationSources(transaction, installationSnapshots, new Date()), + ) + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } + if (error instanceof GitHubApiError && error.status === 404 && phase === "repository") { const removed = await store.lock(async (_records, transaction) => { await lockRepository(transaction, source.id) const current = await transaction.gitHubRepositorySource.findUnique({ @@ -831,30 +915,21 @@ export function createGitHubAuthorizationService({ ) } let token: string + const installationSnapshots = await database.gitHubRepositorySource.findMany({ + where: { + appId: integration.appId, + installationId: integration.installationId, + status: { in: ["active", "suspended"] }, + }, + select: { id: true, synchronizationGeneration: true }, + }) try { token = await createInstallationToken(integration) } catch (error) { if (error instanceof GitHubApiError && error.status === 404) { - await store.lock(async (_records, transaction) => { - for (const source of sources) { - if ( - source.appId !== integration.appId || - source.installationId !== integration.installationId - ) { - continue - } - const current = await transaction.gitHubRepositorySource.findUnique({ - where: { id: source.id }, - }) - if ( - current?.status === "active" && - current.synchronizationGeneration === source.synchronizationGeneration - ) { - await removeRepositorySource(transaction, source.id, new Date(), false) - } - } - await enforceSynchronizedMemberships(transaction, new Date()) - }) + await store.lock((_records, transaction) => + removeInstallationSources(transaction, installationSnapshots, new Date()), + ) } else if (error instanceof GitHubApiError) { for (const source of sources) { if ( @@ -938,7 +1013,7 @@ export function createGitHubAuthorizationService({ } const rawPermission = await permissionFor( token, - profile.username, + (await currentUsername(token, profile.subject)) ?? profile.username, profile.subject, repository.full_name, repository.visibility, @@ -1335,6 +1410,8 @@ export function createGitHubAuthorizationService({ "repository", "member", "membership", + "organization", + "team", "team_add", ]) if (!relevant.has(event)) return "ignored" @@ -1368,8 +1445,8 @@ export function createGitHubAuthorizationService({ } const repositoryId = payload.data.repository?.id const source = repositoryId - ? await transaction.gitHubRepositorySource.findUnique({ - where: { githubRepositoryId: String(repositoryId) }, + ? await transaction.gitHubRepositorySource.findFirst({ + where: { githubRepositoryId: String(repositoryId), installationId }, }) : null await transaction.gitHubWebhookDelivery.create({ @@ -1413,6 +1490,7 @@ export function createGitHubAuthorizationService({ const removedSources = await transaction.gitHubRepositorySource.findMany({ where: { githubRepositoryId: { in: [...removedIds] }, + installationId, status: { in: ["active", "suspended"] }, }, select: { id: true }, @@ -1423,10 +1501,18 @@ export function createGitHubAuthorizationService({ await enforceSynchronizedMemberships(transaction, now) } if (dirtyIds.size > 0) { + const invalidatesSourceIdentity = + event === "repository" || event === "installation_repositories" await transaction.gitHubRepositorySource.updateMany({ - where: { githubRepositoryId: { in: [...dirtyIds] }, status: "active" }, + where: { + githubRepositoryId: { in: [...dirtyIds] }, + installationId, + status: "active", + }, data: { - synchronizationGeneration: { increment: 1 }, + ...(invalidatesSourceIdentity + ? { synchronizationGeneration: { increment: 1 } } + : {}), synchronizationStatus: "dirty", dirtyAt: now, }, @@ -1435,7 +1521,6 @@ export function createGitHubAuthorizationService({ await transaction.gitHubRepositorySource.updateMany({ where: { installationId, status: "active" }, data: { - synchronizationGeneration: { increment: 1 }, synchronizationStatus: "dirty", dirtyAt: now, }, diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts index 5e8502a3..d1dc02f2 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -66,6 +66,7 @@ describe("createIdentityStore()", () => { "audits", "externalIdentity", "githubIntegrationCredentials", + "hasFreshGitHubAdmission", "identityStoreState", "invitations", "membership", diff --git a/hub/server/domains/identity/create-store/index.ts b/hub/server/domains/identity/create-store/index.ts index 7c897f36..54c60f6a 100644 --- a/hub/server/domains/identity/create-store/index.ts +++ b/hub/server/domains/identity/create-store/index.ts @@ -9,6 +9,7 @@ export function createIdentityStore(client: PrismaClient): IdentityStore { const read: IdentityQueries = { githubIntegrationCredentials: () => records.githubIntegrationCredentials(), identityStoreState: () => records.identityStoreState(), + hasFreshGitHubAdmission: (userId, now) => records.hasFreshGitHubAdmission(userId, now), user: (id) => records.user(id), externalIdentity: (provider, issuer, subject) => records.externalIdentity(provider, issuer, subject), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 9f38b1b7..bb8a043b 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -114,6 +114,7 @@ export interface IdentityRecords { export interface IdentityQueries { githubIntegrationCredentials(): Promise identityStoreState(): Promise + hasFreshGitHubAdmission(userId: string, now: Date): Promise user(id: string): Promise externalIdentity( provider: string, diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts index eda189a0..8c984566 100644 --- a/hub/server/integrations/github/check-repository-admission/constants.ts +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -19,6 +19,11 @@ export const collaboratorPermissionSchema = z.object({ role_name: z.string().min(1).max(255).nullable().optional(), }) +export const githubUserSchema = z.object({ + id: z.number().int().positive(), + login: z.string().min(1), +}) + export const repositoryAdmissionBatchSize = 10 export const repositoryAdmissionLimit = 100 export const repositoryAdmissionTimeoutMs = 10_000 diff --git a/hub/server/integrations/github/check-repository-admission/index.test.ts b/hub/server/integrations/github/check-repository-admission/index.test.ts index b067b406..8689e876 100644 --- a/hub/server/integrations/github/check-repository-admission/index.test.ts +++ b/hub/server/integrations/github/check-repository-admission/index.test.ts @@ -32,7 +32,16 @@ describe("checkRepositoryAdmission()", () => { it("does not treat public repository read access as collaboration", async () => { vi.mocked(requestGitHubApi).mockResolvedValue({ - repositories: [{ full_name: "github/public", private: false }], + repositories: [ + { + id: 1, + full_name: "github/public", + name: "public", + owner: { login: "github" }, + default_branch: "main", + private: false, + }, + ], }) await expect(checkRepositoryAdmission(credentials, profile)).resolves.toBe(false) @@ -42,7 +51,16 @@ describe("checkRepositoryAdmission()", () => { it("admits an explicit collaborator on a private installed repository", async () => { vi.mocked(requestGitHubApi) .mockResolvedValueOnce({ - repositories: [{ full_name: "github/private", private: true }], + repositories: [ + { + id: 1, + full_name: "github/private", + name: "private", + owner: { login: "github" }, + default_branch: "main", + private: true, + }, + ], }) .mockResolvedValueOnce({ user: { id: 42 }, @@ -56,7 +74,11 @@ describe("checkRepositoryAdmission()", () => { vi.mocked(requestGitHubApi) .mockResolvedValueOnce({ repositories: Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, full_name: `github/private-${index}`, + name: `private-${index}`, + owner: { login: "github" }, + default_branch: "main", private: true, })), }) From 0bcb5b962647883f78db6a35eb112cee9c47023f Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 09:48:59 -0700 Subject: [PATCH 11/12] fix(hub): integrate rewritten authorization base Preserve bounded legacy admission and provider validation while adapting synchronized access to the rewritten credential and query boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../complete-github-authorization/index.ts | 53 ++++++++++++++----- .../authentication/create-runtime/index.ts | 9 ++-- .../get-current-session/index.ts | 6 ++- .../create-service/index.db.test.ts | 14 ++--- .../create-service/index.ts | 24 +++++---- 5 files changed, 67 insertions(+), 39 deletions(-) diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index 340419fe..f2284e57 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -68,18 +68,36 @@ export async function completeGitHubAuthorization( ) } if (attempt.purpose === "sign-in") { - try { - const admission = await admissionPolicy.admit(profile) - admissionBaseline = admission === true ? "legacy" : admission || null - } catch (error) { - if (error instanceof AuthenticationError) { - admissionFailure = error - } else { - admissionFailure = new AuthenticationError( - 503, - "source_integration_unavailable", - "GitHub source integration is unavailable.", - ) + const policyNeeded = await store.lock(async (records) => { + const identity = await records.externalIdentity( + "github", + configuration.issuer, + profile.subject, + ) + const membership = identity ? await records.membership(identity.userId) : null + const invitation = + membership?.status !== "active" && membership?.status !== "suspended" + ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) + : null + if (admissionPolicy.name === "github-synchronized") { + return !membership || membership.source === "synchronized" || invitation !== null + } + return !membership && invitation === null + }) + if (policyNeeded) { + try { + const admission = await admissionPolicy.admit(profile) + admissionBaseline = admission === true ? "legacy" : admission || null + } catch (error) { + if (error instanceof AuthenticationError) { + admissionFailure = error + } else { + admissionFailure = new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } } } } @@ -313,6 +331,17 @@ export async function completeGitHubAuthorization( suspendedAt: null, removedAt: null, }) + if (admissionBaseline === "legacy") { + await records.audit({ + event: "membership.synchronize", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + context: { source: "legacy_repository" }, + }) + } } } if (admissionBaseline && admissionBaseline !== "legacy") { diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 8a1976ae..f6c062d2 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -72,21 +72,22 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv environment, store, }) - const providerRevision = configuration - ? await ensureGitHubProviderRevision(store, configuration) - : null + const trustedProxyHops = readTrustedProxyHops(environment) const admissionPolicy = createAdmissionPolicy(environment, store, githubAuthorization) const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") if (bootstrapSecret && recoverySecret && bootstrapSecret === recoverySecret) { throw new Error("HUB_BOOTSTRAP_SECRET and HUB_ADMIN_RECOVERY_SECRET must be different.") } + const providerRevision = configuration + ? await ensureGitHubProviderRevision(store, configuration) + : null const settings: AuthenticationSettings = { origin: origin.origin, secureCookies: origin.protocol === "https:", configured: configuration !== null, admissionPolicy: admissionPolicy.name, - trustedProxyHops: readTrustedProxyHops(environment), + trustedProxyHops, } return { administration: createAdministrationService({ store }), diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts index 68baed30..1d374720 100644 --- a/hub/server/domains/authentication/get-current-session/index.ts +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -6,8 +6,10 @@ import { digestSecret } from "#server/utils/digest-secret" export async function getCurrentSession( dependencies: AuthenticationDependencies, sessionToken: string, - records: Pick = - dependencies.store.read, + records: Pick< + IdentityRecords, + "hasFreshGitHubAdmission" | "membership" | "session" + > = dependencies.store.read, ) { const session = sessionToken ? await records.session(digestSecret(sessionToken)) : null if (!session) { diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 42e664e6..3617e62c 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -2992,7 +2992,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { }) expect( fetchMock.mock.calls.some(([input]) => - requestUrl(input).endsWith(`/repositories/${relinked.githubRepositoryId}`), + String(input).endsWith(`/repositories/${relinked.githubRepositoryId}`), ), ).toBe(false) }, @@ -3055,9 +3055,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { await vi.advanceTimersByTimeAsync(1_000) await vi.waitFor(() => expect( - request.mock.calls.filter(([input]) => - requestUrl(input).endsWith("/repositories/100"), - ), + request.mock.calls.filter(([input]) => requestUrl(input).endsWith("/repositories/100")), ).toHaveLength(1), ) }, @@ -3140,9 +3138,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { await vi.advanceTimersByTimeAsync(1_000) await vi.waitFor(() => expect( - request.mock.calls.filter(([input]) => - requestUrl(input).endsWith("/repositories/100"), - ), + request.mock.calls.filter(([input]) => requestUrl(input).endsWith("/repositories/100")), ).toHaveLength(1), ) }, @@ -4307,9 +4303,7 @@ describe("createGitHubAuthorizationService() database behavior", () => { await prisma.$executeRawUnsafe( "DROP TRIGGER IF EXISTS reject_reconciliation_batch_audit ON security_audits", ) - await prisma.$executeRawUnsafe( - "DROP FUNCTION IF EXISTS reject_reconciliation_batch_audit()", - ) + await prisma.$executeRawUnsafe("DROP FUNCTION IF EXISTS reject_reconciliation_batch_audit()") }) vi.stubGlobal( "fetch", diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts index 610fadd3..4fbc4dfb 100644 --- a/hub/server/domains/github-authorization/create-service/index.ts +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -524,17 +524,19 @@ export function createGitHubAuthorizationService({ dirtyRetryTimer = null dirtyRetryRunning = true void (async () => { - const sourceIds = [...dirtyRetrySources].slice(0, 4) - for (const sourceId of sourceIds) dirtyRetrySources.delete(sourceId) - const results = await Promise.allSettled(sourceIds.map((sourceId) => reconcile(sourceId))) - results.forEach((result, index) => { - if (result.status === "rejected") { - log("error", "GitHub authorization dirty retry failed", { - repositorySourceId: sourceIds[index], - reason: "reconciliation_failed", - }) - } - }) + while (dirtyRetrySources.size > 0) { + const sourceIds = [...dirtyRetrySources].slice(0, 4) + for (const sourceId of sourceIds) dirtyRetrySources.delete(sourceId) + const results = await Promise.allSettled(sourceIds.map((sourceId) => reconcile(sourceId))) + results.forEach((result, index) => { + if (result.status === "rejected") { + log("error", "GitHub authorization dirty retry failed", { + repositorySourceId: sourceIds[index], + reason: "reconciliation_failed", + }) + } + }) + } })().finally(() => { dirtyRetryRunning = false if (dirtyRetrySources.size > 0) scheduleDirtyReconciliation([]) From efda2b8614b72b2334af2ec1b03e9062330cecfe Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 10:26:22 -0700 Subject: [PATCH 12/12] test(hub): stabilize dirty reconciliation retry Assert the eventual follow-up source and state instead of freezing request counts that vary with concurrent batch completion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts index 3617e62c..7bc69011 100644 --- a/hub/server/domains/github-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -4619,15 +4619,17 @@ describe("createGitHubAuthorizationService() database behavior", () => { ), ) await vi.advanceTimersByTimeAsync(1_000) - await vi.waitFor(() => expect(requestedRepositoryIds).toHaveLength(8)) - await vi.advanceTimersByTimeAsync(1_000) await vi.waitFor(() => expect(requestedRepositoryIds).toContain("500")) expect(maximumConcurrentRepositoryRequests).toBe(4) expect(requestedRepositoryIds).toContain(laterSource.githubRepositoryId) - expect( - await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: laterSource.id } }), - ).toMatchObject({ synchronizationStatus: "current" }) + await vi.waitFor(async () => + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ + where: { id: laterSource.id }, + }), + ).toMatchObject({ synchronizationStatus: "current" }), + ) expect(initialSources).toHaveLength(4) })