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..9044c181 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -48,7 +48,19 @@ 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, 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. + +`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 diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md new file mode 100644 index 00000000..563d76b9 --- /dev/null +++ b/hub/docs/github-integration.md @@ -0,0 +1,56 @@ +# 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, 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. + +## 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. 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. 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. 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 + +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. 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. + +## 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. Repository listing requires the active administrator role but not a fresh verification because it is read-only. Product policy changes require a fresh effective prototype administrator. Request bodies are omitted from logs, responses are private and non-cacheable, and mutations write structured audits in the same transaction. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 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..6a8db644 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, 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. ## 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..5ead8eca 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,25 +25,40 @@ 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. + +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 | +| --- | --- | +| `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. 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. 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 @@ -55,6 +70,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 +86,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..4a24ff39 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 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. 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-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-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..7113a058 --- /dev/null +++ b/hub/schema/github-repository-source.prisma @@ -0,0 +1,36 @@ +model GitHubRepositorySource { + // ID columns + id String @id @default(uuid()) @db.Uuid + githubRepositoryId String @unique @map("github_repository_id") + + // Relations + prototypes Prototype[] + synchronizedAccess GitHubSynchronizedAccess[] + admissionAttempts GitHubAdmissionAttempt[] + 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") + visibility String @default("unknown") + 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) + 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..47d1f6f1 --- /dev/null +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -0,0 +1,153 @@ +-- 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'); + +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, + "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, + "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, + "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', 'suspended') + ), + CONSTRAINT "github_repository_sources_synchronization_generation" CHECK ( + "synchronization_generation" >= 0 + ), + 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, + "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 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"); +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_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; +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.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 bb38952c..28744d4f 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, @@ -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/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index 398959a1..f2284e57 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -45,30 +45,29 @@ 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") { + if (attempt.purpose === "sign-in") { const policyNeeded = await store.lock(async (records) => { const identity = await records.externalIdentity( "github", @@ -77,20 +76,32 @@ export async function completeGitHubAuthorization( ) const membership = identity ? await records.membership(identity.userId) : null const invitation = - !membership || membership.status === "removed" + membership?.status !== "active" && membership?.status !== "suspended" ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) : null - return !membership && !invitation + if (admissionPolicy.name === "github-synchronized") { + return !membership || membership.source === "synchronized" || invitation !== null + } + return !membership && invitation === null }) if (policyNeeded) { try { - policyAdmitted = await admissionPolicy.admit(profile) + const admission = await admissionPolicy.admit(profile) + admissionBaseline = admission === true ? "legacy" : admission || null } catch (error) { - await reportProviderFailure(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 +140,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 +322,39 @@ 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 === "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") { + 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 +378,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..f6c062d2 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,16 +67,21 @@ 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 githubAuthorization = createGitHubAuthorizationService({ + database: prisma, + environment, + store, + }) const trustedProxyHops = readTrustedProxyHops(environment) - 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) { 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:", @@ -85,7 +91,19 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv } 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..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) @@ -449,7 +516,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/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index 6ff60b90..36bf2064 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") @@ -250,7 +251,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 +302,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 +344,98 @@ 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 for a new identity when synchronized admission is unavailable", 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() + .mockRejectedValue( + new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ), + ), + 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).not.toHaveBeenCalled() }) it("admits an active Hub member without consulting legacy repository policy", async () => { @@ -607,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/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/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts index 2ec3974b..1d374720 100644 --- a/hub/server/domains/authentication/get-current-session/index.ts +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -6,7 +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) { @@ -16,5 +19,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 new file mode 100644 index 00000000..7bc69011 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -0,0 +1,4725 @@ +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" + +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 { logger } from "#server/integrations/pino" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +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(environmentOverrides: NodeJS.ProcessEnv = {}) { + 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(), ...environmentOverrides }, + store, + }), + } +} + +async function createUser( + store: IdentityStore, + { + subject, + membershipSource = "manual", + role = "member", + status = "active", + token, + }: { + subject: string + membershipSource?: "legacy-synchronized" | "manual" | "synchronized" + role?: "administrator" | "member" + status?: "active" | "removed" | "suspended" + 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, + role, + source: membershipSource, + suspendedAt: status === "suspended" ? new Date() : null, + removedAt: status === "removed" ? new Date() : null, + }) + 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")}`, + } +} + +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 +} + +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, + 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() + 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 saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + 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, 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", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + 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 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.gitHubRepositorySource.findUnique({ where: { id: unrelatedSource.id } }), + ).toMatchObject({ + synchronizationGeneration: unrelatedSource.synchronizationGeneration, + synchronizationStatus: "current", + dirtyAt: null, + }) + 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", + visibility: "private", + }, + { id: 42, login: "user-42" }, + { 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/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( + "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("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" }) + 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 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 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", + visibility: "private", + }), + { 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("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({ + 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: "renamed", + installation: { id: 7 }, + repository: { + id: 100, + name: "renamed", + owner: { login: "new-owner" }, + default_branch: "main", + visibility: "private", + }, + }, + "repository", + "source-generation-changed-before-claim", + ), + ) + continueAdmission.resolve() + + 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", + ]) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "dirty", + }) + }) + + 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" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const repositoryResponse = deferred() + const repositoryRequested = 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 + 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 }), + ) + }), + ) + + const firstReconciliation = service.reconcileDue() + await repositoryRequested.promise + + await expect(service.reconcileDue()).resolves.toBeUndefined() + expect(repositoryRequests).toBe(1) + + repositoryResponse.resolve( + new Response( + JSON.stringify({ + id: Number(source.githubRepositoryId), + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + await firstReconciliation + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "current", + synchronizationGeneration: 1, + }) + }) + + 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 }, + ), + ) + } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + 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, { + subject: "42", + membershipSource: "synchronized", + }) + 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 saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: sourceA.id, + sourceGeneration: sourceA.synchronizationGeneration, + 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.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", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + 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.endsWith("/repositories/200")) { + 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, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: sourceA.id, + userId: actor.user.id, + }, + }, + }), + ).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 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({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const confirmedAt = new Date() + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt, + }) + 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 })) + }), + ) + + 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([ + { 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 saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + 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" }) + 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 saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + 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, + subject: actor.identity.subject, + admissionGeneration: 1, + persist: true, + 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, + subject: actor.identity.subject, + admissionGeneration: 1, + persist: true, + 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", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + if ( + url.endsWith("/repos/renamed-owner/renamed-repository/collaborators/user-42/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, + subject: "42", + admissionGeneration: 1, + persist: true, + 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/user/42", + "https://api.github.com/repos/renamed-owner/renamed-repository/collaborators/user-42/permission", + ]) + }, + ) + + 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 }, + ), + ) + } + 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/user-42/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/user/42", + "https://api.github.com/repos/owner/repository/collaborators/user-42", + ...(admitted + ? ["https://api.github.com/repos/owner/repository/collaborators/user-42/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, { + 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", + 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: "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() + + 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", + visibility: "private", + }), + { 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 () => { + 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: { + 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, + }) + expect(errorLog).toHaveBeenCalledWith( + { + repositorySourceId: source.id, + reason: "repository_source_configuration_changed", + }, + "GitHub authorization reconciliation failed", + ) + }) + + 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("records a malformed provider response separately from provider availability", 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", + }, + }) + 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" }), { + status: 200, + }), + ) + } + throw new Error(`Unexpected request: ${url}`) + }), + ) + + 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 }, + ), + ) + } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + 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, { + 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, { + 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", + visibility: "private", + }), + { 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.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, { + 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 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( + webhook( + { + action: "deleted", + installation: { id: 7 }, + repository: { + id: 100, + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }, + }, + "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", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + 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 + 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("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 }, + ), + ) + } + 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" }), { + 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.lock((records) => + records.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 }, + ), + ) + } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + 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, { + 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.lock((records) => + records.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", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + 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("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", + }) + 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.lock((records) => + records.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 }, + ), + ) + } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + 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, { + 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 saveAdmissionFixture(prisma, service, target.user.id, target.identity.subject, { + 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", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + 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("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", + }) + const lost = await createUser(store, { + subject: "43", + membershipSource: "synchronized", + token: "lost-token", + }) + 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, lost.user.id, lost.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const targetPermission = deferred() + const targetPermissionRequested = deferred() + 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 user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + if (url.includes("user-42")) { + 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 }), + ) + }), + ) + + const reconciliation = service.reconcileDue() + await targetPermissionRequested.promise + const now = new Date() + const provider = await store.lock((records) => + records.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 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), + }, + }) + + 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.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" }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + admissionEligible: true, + status: "active", + }) + 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("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.each([ + { + name: "repository removal", + event: "repository", + payload: { + action: "deleted", + installation: { id: 7 }, + repository: { + id: 100, + name: "relinked", + owner: { login: "owner" }, + default_branch: "main", + }, + }, + }, + { + name: "permission change", + event: "member", + payload: { + action: "edited", + installation: { id: 7 }, + repository: { + id: 100, + name: "relinked", + owner: { login: "owner" }, + default_branch: "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, + synchronizationStatus: "current", + synchronizationGeneration: relinked.synchronizationGeneration, + dirtyAt: null, + }) + expect( + fetchMock.mock.calls.some(([input]) => + String(input).endsWith(`/repositories/${relinked.githubRepositoryId}`), + ), + ).toBe(false) + }, + ) + + 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") + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "dirty", + synchronizationGeneration: source.synchronizationGeneration, + }) + + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => + expect( + request.mock.calls.filter(([input]) => requestUrl(input).endsWith("/repositories/100")), + ).toHaveLength(1), + ) + }, + ) + + 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) => + 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("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, { + 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 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 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("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("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.lock((records) => + records.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("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" }, + ] 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 }, + ), + ) + } + 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 }), + ) + }), + ) + + 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.lock((records) => + records.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 user = currentGitHubUserResponse(url) + if (user) return Promise.resolve(user) + 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 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( + 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.securityAudit.count({ + where: { + event: "github.repository.reconcile.batch", + githubRepositorySourceId: source.id, + }, + }), + ).toBe(2) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "current", + }) + }) + + 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" }) + 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 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).toContain("500")) + + expect(maximumConcurrentRepositoryRequests).toBe(4) + expect(requestedRepositoryIds).toContain(laterSource.githubRepositoryId) + await vi.waitFor(async () => + 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( + "../../../../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, { + 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 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 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..4fbc4dfb --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -0,0 +1,1567 @@ +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, + githubUserSchema, + 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 { 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" +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().optional(), +}) +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, + requireFresh = true, +) { + const actor = await getCurrentSession( + authenticationDependencies(store), + sessionToken, + database ? createIdentityRecords(database) : store.read, + ) + if ( + actor.membership.role !== "administrator" || + (requireFresh && + (!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.githubIntegrationCredentials()) + 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, + 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`, + token, + ), + ) + 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" + throw error + } +} + +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, +): 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 + ` +} + +async function saveBaseline( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + requireAdmissionEnabled = false, + skipOlderConfirmation = false, + requireAdmissionAttempt = true, +): Promise<"saved" | "superseded"> { + await lockRepository(database, baseline.repositorySourceId) + const source = await database.gitHubRepositorySource.findUnique({ + where: { id: baseline.repositorySourceId }, + select: { + status: true, + admissionEnabled: true, + synchronizationGeneration: true, + }, + }) + if ( + !source || + source.status !== "active" || + (requireAdmissionEnabled && !source.admissionEnabled) || + source.synchronizationGeneration !== baseline.sourceGeneration + ) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "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: { + repositorySourceId_subject: { + repositorySourceId: baseline.repositorySourceId, + subject: baseline.subject, + }, + }, + select: { generation: true }, + }) + if (!attempt || attempt.generation !== baseline.admissionGeneration) { + if (skipOlderConfirmation) return "superseded" + throw new AuthenticationError( + 409, + "github_authorization_changed", + "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) { + if (skipOlderConfirmation) return "superseded" + 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, + status: baseline.admissionEligible ? "active" : "removed", + synchronizationStatus: baseline.admissionEligible ? "current" : "removed", + lastConfirmedAt: baseline.confirmedAt, + removedAt: baseline.admissionEligible ? null : 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, + }, + }) + return "saved" +} + +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", + 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 [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", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) + } +} + +async function removeRepositorySource( + database: Prisma.TransactionClient, + repositorySourceId: string, + removedAt: Date, + enforceMemberships = true, +): Promise { + await lockRepository(database, repositorySourceId) + await database.gitHubRepositorySource.update({ + where: { id: repositorySourceId }, + data: { + status: "removed", + admissionEnabled: false, + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "removed", + removedAt, + }, + }) + await database.gitHubSynchronizedAccess.updateMany({ + where: { repositorySourceId }, + data: { status: "removed", synchronizationStatus: "removed", removedAt }, + }) + 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, + 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, + 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.") + } + let reconciliationDueRunning = false + const dirtyRetrySources = new Set() + let dirtyRetryTimer: NodeJS.Timeout | null = null + let dirtyRetryRunning = false + + async function sourceCredentials() { + return credentials(store, createGitHubIntegrationCredentialProvider(environment)) + } + + function scheduleDirtyReconciliation(sourceIds: string[]) { + for (const sourceId of sourceIds) dirtyRetrySources.add(sourceId) + if (dirtyRetryTimer || dirtyRetryRunning || dirtyRetrySources.size === 0) return + dirtyRetryTimer = setTimeout(() => { + dirtyRetryTimer = null + dirtyRetryRunning = true + void (async () => { + 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([]) + }) + }, 1_000) + dirtyRetryTimer.unref() + } + + async function claimAdmissionGeneration( + repositorySourceId: string, + subject: string, + observedSourceGeneration: number, + requireAdmissionEnabled: boolean, + ) { + return database.$transaction(async (transaction) => { + await lockRepository(transaction, repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + 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 { status: "claimed" as const, source, admissionGeneration: attempt.generation } + }) + } + + async function reconcile(repositorySourceId: string, actorSessionToken?: string) { + 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, + }, + }) + }) + 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() + 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.", + ) + } + 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) { + const page = await database.externalIdentity.findMany({ + 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 } : {}), + take: 100, + }) + identities.push(...page) + if (page.length < 100) break + identityCursor = page.at(-1)?.id + } + const results = await mapWithConcurrency(identities, 10, async (identity) => { + const claim = await claimAdmissionGeneration( + source.id, + identity.subject, + source.synchronizationGeneration, + false, + ) + 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, + username, + identity.subject, + repository.full_name, + repository.visibility, + ) + return { + status: "confirmed" as const, + userId: identity.userId, + subject: identity.subject, + admissionGeneration: claim.admissionGeneration, + rawPermission, + confirmedAt: new Date(), + ...mapGitHubPermission(rawPermission), + } + } catch { + return { status: "failed" as const } + } + }) + 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, + ) + } + 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) => { + 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 + } + if (actorSessionToken) { + await currentAdministrator(store, actorSessionToken, transaction, false) + } + await transaction.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + visibility: repository.visibility, + status: "active", + removedAt: null, + synchronizationStatus: partialFailure ? "failed" : "current", + synchronizationError: partialFailure ? "permission_partial_failure" : null, + dirtyAt: null, + ...(!partialFailure && confirmedAt ? { lastConfirmedAt: confirmedAt } : {}), + }, + }) + await enforceSynchronizedMemberships(transaction, confirmedAt ?? new Date()) + 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 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 && 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({ + 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 } }) + } + 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, + "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.findMany({ + where: { + userId: identity.userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySourceId: { in: sources.map(({ id }) => id) }, + }, + }) + : [] + const cachedBySource = new Map( + cached.map((baseline) => [baseline.repositorySourceId, baseline]), + ) + const membership = identity?.user.memberships[0] + let integration: GitHubCredentials + 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 + 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((_records, transaction) => + removeInstallationSources(transaction, installationSnapshots, new Date()), + ) + } else 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, + subject: profile.subject, + admissionGeneration: 0, + persist: false, + 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 claim = await claimAdmissionGeneration( + source.id, + profile.subject, + source.synchronizationGeneration, + true, + ) + 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( + await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), + ) + } catch (error) { + 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 rawPermission = await permissionFor( + token, + (await currentUsername(token, profile.subject)) ?? profile.username, + profile.subject, + repository.full_name, + repository.visibility, + ) + const mapped = mapGitHubPermission(rawPermission) + if (mapped.admissionEligible) { + return { + repositorySourceId: source.id, + sourceGeneration: claim.source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: claim.admissionGeneration, + persist: true, + rawPermission, + ...mapped, + confirmedAt: new Date(), + } + } + if (identity) { + await store.lock((_records, transaction) => + saveBaseline(transaction, identity.userId, { + repositorySourceId: source.id, + sourceGeneration: claim.source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: claim.admissionGeneration, + persist: true, + rawPermission, + ...mapped, + confirmedAt: new Date(), + }), + ) + } + } 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, + sourceGeneration: source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: 0, + persist: false, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } + } + } + } + if (providerUnavailable) { + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + return null + }, + + async saveAdmissionBaseline(transaction, userId, baseline) { + await 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 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 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" } }) + }, + + 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) + 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({ + 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, + visibility: repository.visibility, + admissionEnabled: parsed.data.admissionEnabled ?? false, + dirtyAt: new Date(), + }, + update: { + appId: integration.appId, + installationId: integration.installationId, + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + visibility: repository.visibility, + status: "active", + removedAt: null, + ...(parsed.data.admissionEnabled === undefined + ? {} + : { admissionEnabled: parsed.data.admissionEnabled }), + synchronizationGeneration: { increment: 1 }, + 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 || !["active", "suspended"].includes(source.status)) { + 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 || !["active", "suspended"].includes(source.status)) { + 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() { + if (reconciliationDueRunning) return + reconciliationDueRunning = true + try { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active" }, + select: { id: true }, + }) + for (const source of sources) { + try { + 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 }, + select: { synchronizationError: true }, + }) + log("error", "GitHub authorization reconciliation failed", { + repositorySourceId: source.id, + reason: failed?.synchronizationError ?? "reconciliation_failed", + }) + } + } + await store.lock((_records, transaction) => + enforceSynchronizedMemberships(transaction, new Date()), + ) + } finally { + reconciliationDueRunning = false + } + }, + + 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 signatureBuffer = Buffer.from(signature) + const expectedBuffer = Buffer.from(expected) + const valid = + signatureBuffer.length === expectedBuffer.length && + timingSafeEqual(signatureBuffer, expectedBuffer) + 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.", + ) + } + const relevant = new Set([ + "installation", + "installation_repositories", + "repository", + "member", + "membership", + "organization", + "team", + "team_add", + ]) + if (!relevant.has(event)) return "ignored" + 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 installationId = String(payload.data.installation.id) + try { + 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.findFirst({ + where: { githubRepositoryId: String(repositoryId), installationId }, + }) + : null + await transaction.gitHubWebhookDelivery.create({ + data: { + deliveryId, + event, + action: payload.data.action ?? null, + installationId, + repositorySourceId: source?.id ?? null, + }, + }) + const now = new Date() + if (event === "installation" && payload.data.action === "deleted") { + const sources = await transaction.gitHubRepositorySource.findMany({ + where: { + installationId, + status: { in: ["active", "suspended"] }, + }, + select: { id: true }, + }) + for (const source of sources) { + await removeRepositorySource(transaction, source.id, now, false) + } + await enforceSynchronizedMemberships(transaction, now) + } else if (event === "installation" && payload.data.action === "suspend") { + await suspendInstallationSources(transaction, installationId, now) + } else if (event === "installation" && payload.data.action === "unsuspend") { + await resumeInstallationSources(transaction, installationId, now) + } 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 ?? []) + 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] }, + installationId, + status: { in: ["active", "suspended"] }, + }, + select: { id: true }, + }) + for (const source of removedSources) { + await removeRepositorySource(transaction, source.id, now, false) + } + await enforceSynchronizedMemberships(transaction, now) + } + if (dirtyIds.size > 0) { + const invalidatesSourceIdentity = + event === "repository" || event === "installation_repositories" + await transaction.gitHubRepositorySource.updateMany({ + where: { + githubRepositoryId: { in: [...dirtyIds] }, + installationId, + status: "active", + }, + data: { + ...(invalidatesSourceIdentity + ? { synchronizationGeneration: { increment: 1 } } + : {}), + synchronizationStatus: "dirty", + dirtyAt: now, + }, + }) + } else if (removedIds.size === 0) { + await transaction.gitHubRepositorySource.updateMany({ + where: { 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" }, + }) + 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 && + "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..9c8c6ab1 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -0,0 +1,64 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +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 + subject: string + admissionGeneration: number + persist: boolean + 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 | PrismaClient, + userId: string, + prototypeId: string, + now?: Date, + ): Promise + prototypeWorkspaceIds( + database: Prisma.TransactionClient | PrismaClient, + 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( + 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/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/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 952d0d8c..bb8a043b 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 @@ -113,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/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 98ae7e05..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 @@ -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, @@ -224,6 +226,154 @@ 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 + authorizationMode: string + sourceRepositoryId: string | null + } + expect(prototype).toMatchObject({ + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }) + 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, + }) + 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", + 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, { @@ -1546,9 +1696,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,12 +1732,19 @@ 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([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", @@ -1630,7 +1794,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, {}), @@ -1662,10 +1826,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( @@ -1681,10 +1846,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", @@ -1740,15 +1905,15 @@ 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 () => { - 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 +1955,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", @@ -1835,15 +2000,370 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(Object.keys(page.items[0] ?? {}).sort()).toEqual( [ "archivedAt", + "authorizationMode", "createdAt", "createdByUserId", "id", "name", + "sourceRepositoryId", "updatedAt", "workspaceId", ].sort(), ) - expect(baselineRole.mock.calls).toHaveLength(0) + 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" }) + 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 () => { + 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("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 + .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 () => { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 3563da47..8f116720 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(), } } @@ -152,6 +156,34 @@ 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 + 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( database: Prisma.TransactionClient, workspaceId: string, @@ -166,6 +198,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, @@ -215,14 +259,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, } } @@ -307,6 +353,8 @@ function prototypeDto(prototype: { workspaceId: string createdByUserId: string name: string + authorizationMode: string + sourceRepositoryId: string | null archivedAt: Date | null createdAt: Date updatedAt: Date @@ -316,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, @@ -324,6 +374,7 @@ function prototypeDto(prototype: { async function prototypeAccessDecision( baseline: PrototypeBaselinePolicy | null, + database: Prisma.TransactionClient | PrismaClient, userId: string, workspaceGrant: GrantInput | null, prototype: { @@ -337,10 +388,71 @@ async function prototypeAccessDecision( }) if (hubDecision.source !== "none" || !baseline) return hubDecision return evaluatePrototypeAccess({ - baselineRole: await baseline.role(userId, prototype.id), + baseline: await baseline.baseline(database, userId, prototype.id), }) } +async function readAccessibleWorkspacePage({ + baseline, + database, + userId, + cursor, + limit, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + 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: { + ...(scanCursor ? { id: { gt: scanCursor } } : {}), + OR: [ + { grants: { some: { userId } } }, + { prototypes: { some: { grants: { some: { userId } } } } }, + ...(baselineWorkspaceIds.length > 0 ? [{ id: { in: baselineWorkspaceIds } }] : []), + ], + }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + 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, @@ -358,10 +470,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, @@ -373,11 +486,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, @@ -385,7 +533,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 } @@ -409,33 +563,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, 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( @@ -467,25 +630,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) { @@ -525,7 +676,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, @@ -580,6 +742,7 @@ export function createPrototypeAuthorizationService({ create: { workspaceId, userId: targetId, ...change }, update: change, }) + await preserveManualAdministration(transaction, actor, targetId, change) await audit(transaction, actor, { event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", workspaceId, @@ -705,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, @@ -749,6 +914,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( + baseline, + transaction, + actor.userId, + prototypeId, + ) + 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( @@ -781,6 +1004,7 @@ export function createPrototypeAuthorizationService({ create: { prototypeId, userId: targetId, ...change }, update: 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 fc197973..ba9ad592 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, PrototypeRole } from "../evaluate-access/types.ts" +import type { AccessDecision, AccessInput, PrototypeRole } from "../evaluate-access/types.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" export interface Page { items: T[] @@ -24,6 +25,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 +53,18 @@ export interface GrantInput { } export interface PrototypeBaselinePolicy { - role(userId: string, prototypeId: string): Promise + baseline( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + prototypeId: string, + ): Promise + workspaceIds?( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + ): Promise + prototypeIds?( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + workspaceId: 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..8c984566 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(), }), ), @@ -12,8 +16,23 @@ 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 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 + +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), + visibility: z.enum(["internal", "private", "public"]), +}) 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, })), }) 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/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 + ) +} 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 } 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.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", 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 }