From 373cfafa23f1f007113906ac5a5f09de04a7a5ea Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 14:15:03 -0700 Subject: [PATCH 01/10] feat(hub): add prototype authorization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/README.md | 1 + hub/docs/architecture.md | 6 +- hub/docs/database.md | 4 +- hub/docs/identity.md | 4 +- hub/docs/membership-and-administration.md | 4 +- hub/docs/prototype-authorization.md | 71 +++ hub/docs/testing-and-operations.md | 2 + hub/schema/access-effect.enum.prisma | 4 + .../migration.sql | 109 ++++ hub/schema/prototype-access-grant.prisma | 22 + hub/schema/prototype-role.enum.prisma | 6 + hub/schema/prototype-version.prisma | 22 + hub/schema/prototype.prisma | 24 + hub/schema/security-audit.prisma | 8 + hub/schema/user.prisma | 5 + hub/schema/workspace-access-grant.prisma | 22 + hub/schema/workspace.prisma | 20 + hub/server/create-app/index.ts | 5 +- .../authentication/create-runtime/index.ts | 2 + .../create-service/index.db.test.ts | 483 ++++++++++++++ .../create-service/index.ts | 588 ++++++++++++++++++ .../create-service/types.ts | 47 ++ .../evaluate-access/index.test.ts | 78 +++ .../evaluate-access/index.ts | 49 ++ .../evaluate-access/types.ts | 19 + .../express/create-rate-limit/index.ts | 4 +- .../security-audits/save-audit/index.ts | 4 + .../repositories/security-audits/types.ts | 8 + hub/server/routes/administration.test.ts | 2 + hub/server/routes/authentication.test.ts | 2 + hub/server/routes/index.ts | 71 ++- hub/server/routes/product/index.test.ts | 252 ++++++++ hub/server/routes/product/index.ts | 219 +++++++ hub/server/routes/types.ts | 2 + 34 files changed, 2158 insertions(+), 11 deletions(-) create mode 100644 hub/docs/prototype-authorization.md create mode 100644 hub/schema/access-effect.enum.prisma create mode 100644 hub/schema/migrations/20261027220000_prototype_authorization/migration.sql create mode 100644 hub/schema/prototype-access-grant.prisma create mode 100644 hub/schema/prototype-role.enum.prisma create mode 100644 hub/schema/prototype-version.prisma create mode 100644 hub/schema/prototype.prisma create mode 100644 hub/schema/workspace-access-grant.prisma create mode 100644 hub/schema/workspace.prisma create mode 100644 hub/server/domains/prototype-authorization/create-service/index.db.test.ts create mode 100644 hub/server/domains/prototype-authorization/create-service/index.ts create mode 100644 hub/server/domains/prototype-authorization/create-service/types.ts create mode 100644 hub/server/domains/prototype-authorization/evaluate-access/index.test.ts create mode 100644 hub/server/domains/prototype-authorization/evaluate-access/index.ts create mode 100644 hub/server/domains/prototype-authorization/evaluate-access/types.ts create mode 100644 hub/server/routes/product/index.test.ts create mode 100644 hub/server/routes/product/index.ts diff --git a/hub/docs/README.md b/hub/docs/README.md index 8586fc1f..dc97844e 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -9,6 +9,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Database](database.md): Prisma schema conventions, migrations, and the shared client. - [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. - [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. +- [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, direct roles, explicit deny, and policy precedence. - [Logging](logging.md): application events, automatic request records, and payload safety. - [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 9f73c369..a3653079 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check and provides GitHub authentication, Hub sessions, and installation membership administration. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub authentication, Hub sessions, installation membership administration, and Hub-native prototype authorization. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [background-job foundation](background-jobs.md) provides PostgreSQL-backed queues and workers without product jobs. Deployment and prototype APIs have not been built. This app cannot yet evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [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. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication and administration runtime and starts the API at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Product routes create and read workspaces, prototypes, and immutable versions; archive prototypes; explain effective access; and administer direct access rules. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, and prototype authorization runtime and starts the API at a local address. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. diff --git a/hub/docs/database.md b/hub/docs/database.md index 386e4a56..31f864dc 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. The initial migration stores the provider-neutral [identity foundation](identity.md); product models have not been added. +Hub uses PostgreSQL and Prisma. The schema stores the provider-neutral [identity foundation](identity.md) and the [prototype authorization](prototype-authorization.md) product core. [Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration. @@ -48,6 +48,8 @@ model Folder { `uuid()` generates UUIDs through Prisma. `@updatedAt` updates timestamps on Prisma writes, not through a database trigger. Raw SQL writers must supply UUIDs and maintain `updated_at` themselves. Use timezone-aware timestamps for stored instants. +Workspace and prototype access use separate grant tables so each user has at most one rule per scope. The database requires an allowed rule to have a role and an explicit deny to have no role. Prototype versions have a unique sequence within their owning prototype and use restrictive deletion so history cannot be detached or silently removed. + ## Shared client Import `prisma` from `server/integrations/prisma/index.ts` rather than constructing clients in each caller. The integration uses Prisma's [documented global cache pattern](https://www.prisma.io/docs/orm/v7/more/troubleshooting/nextjs#avoiding-multiple-prisma-client-instances) to reuse one client across development module reloads. Production uses the module instance without setting the global variable. diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 1c495ca6..80ca1448 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -25,7 +25,7 @@ Sign-out revokes the current database session. Every restored session requires a - `AuthenticationProvider` and its versioned revisions store provider-neutral authentication configuration. An OIDC-only configuration does not require GitHub source-integration credentials. - `Session` records the Hub user, external identity, and authentication-provider revision that established it. GitHub source-integration credential rotation does not change session identity context. - `AuthorizationAttempt` stores digested state and browser bindings, a PKCE verifier, an optional OIDC nonce, provider revision, purpose, optional originating session, and expiry. Consumption deletes the record in the same transaction so concurrent callbacks cannot reuse it. -- `SecurityAudit` stores the event and minimal optional identity context. +- `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. @@ -54,6 +54,6 @@ Database credentials use AES-256-GCM with contextual additional authenticated da ## Current scope -This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. It does not add prototype, repository, workspace, or artifact roles; GitHub membership synchronization; artifact ingestion; production OIDC; account linking; SCIM; or prototype APIs. +This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary uses these sessions without treating membership or repository access as product authority. Identity does not add GitHub membership synchronization, artifact ingestion, production OIDC, account linking, or SCIM. Apply migrations 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 1a1a8cb5..eb6b4f3c 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -9,7 +9,7 @@ Each Hub user has at most one installation membership. | State | Result | | --- | --- | | Active member | May create and restore a Hub session. Product access remains a separate future decision. | -| Active administrator | Has member access and may use installation administration after fresh identity verification. | +| Active administrator | Has member access and may use installation administration after fresh identity verification. Existing product access remains separate. | | Suspended | Cannot create or use a Hub session. An administrator may restore the membership. | | Removed | Cannot create or use a Hub session. The retained record preserves audit linkage, and return requires a new invitation. | @@ -59,4 +59,4 @@ Membership operations write a structured security record with a bounded action, ## Non-goals -This boundary does not define prototype, repository, workspace, or artifact roles. It does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. +This boundary does not grant prototype, repository, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines Hub-managed product access. Membership does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md new file mode 100644 index 00000000..17e97e36 --- /dev/null +++ b/hub/docs/prototype-authorization.md @@ -0,0 +1,71 @@ +# Prototype authorization + +Hub owns prototype access after authentication and installation admission. A user does not need repository access to use a Hub-managed prototype, and GitHub repository permissions do not grant Hub access. + +## Product records + +A workspace owns prototypes. A prototype owns an ordered history of immutable versions. + +- An active installation administrator may create a workspace. Creation also writes an explicit workspace `administrator` grant for that user in the same transaction. +- A workspace `editor` or `administrator` may create a prototype. +- A prototype `editor` or `administrator` may create its next version. +- A version cannot move to another prototype or be changed after creation. +- Archiving a prototype keeps its versions readable but prevents new versions. + +Artifact payloads are not part of this layer. A version identifies immutable prototype history without storing a build, manifest, or upload. + +## Roles + +| Role | Product authority | +| --- | --- | +| `viewer` | Read the authorized workspace, prototype, and versions. | +| `commenter` | Includes viewer authority and reserves the role for future review comments. | +| `editor` | Includes commenter authority, creates prototypes within an authorized workspace, creates versions, and archives prototypes. | +| `administrator` | Includes editor authority and changes direct access rules. | + +Installation membership and installation administrator status do not grant access to existing workspaces or prototypes. The workspace-creation rule is the only installation-administrator product rule in this layer. After creation, the user's explicit workspace grant supplies authority. + +## Direct access and precedence + +Hub stores one direct workspace rule and one direct prototype rule per user and scope. A rule is either an explicit deny or an allowed role. Removing a rule restores evaluation from the remaining sources. + +The evaluator applies this order: + +1. An explicit Hub deny at either the workspace or prototype scope denies access. +2. An explicit prototype role applies, including a lower role that intentionally restricts a stronger workspace role. +3. An explicit workspace role applies. +4. A future optional synchronized baseline may apply. +5. Otherwise Hub denies access. + +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. + +Workspace administrators cannot remove or replace the final direct workspace administrator. A prototype access change must also leave at least one effective prototype administrator. A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. + +## API and request boundaries + +The product API uses: + +- `GET` and `POST /api/workspaces` +- `GET /api/workspaces/:workspaceId` +- `PUT` and `DELETE /api/workspaces/:workspaceId/access/:userId` +- `GET` and `POST /api/workspaces/:workspaceId/prototypes` +- `GET /api/prototypes/:prototypeId` +- `POST /api/prototypes/:prototypeId/archive` +- `GET /api/prototypes/:prototypeId/access/explanation` +- `PUT` and `DELETE /api/prototypes/:prototypeId/access/:userId` +- `GET` and `POST /api/prototypes/:prototypeId/versions` +- `GET /api/prototypes/:prototypeId/versions/:versionId` + +List routes use an opaque UUID cursor and a limit from 1 through 100. Invalid pagination returns an explicit error. State-changing requests require the configured origin and matching CSRF cookie and header. Product responses are private and non-cacheable, request payloads are omitted from logs, and mutation routes use a bounded rate limit. + +Every product route restores the current session and active installation membership before evaluating product access. Workspace creation and direct access changes also require identity verification from the last five minutes. Mutations repeat the membership check inside their database transaction so a suspended or removed actor cannot complete a stale request. + +## Audits + +Workspace creation, prototype creation and archival, version creation, role grants, explicit denies, and rule removal write structured security audits in the same transaction as the product change. A failed audit rolls back the product change. + +Records identify the actor, session, authentication-provider revision, affected product records, target user when applicable, outcome, and a bounded reason. They do not include cookies, provider tokens, host secrets, request bodies, prototype artifacts, or mutable identity labels. + +## Deferred policy sources + +GitHub repository synchronization, webhooks, reconciliation, and repository-to-role mapping are not implemented. They may later produce an optional baseline role without changing Hub's direct-rule precedence. Source-provider mutations, public links, artifact ingestion, promotion, retention, and rollback remain separate product boundaries. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 493618fe..0a80f046 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,6 +57,8 @@ 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, overlapping grant changes, audit rollback, route enforcement, bounded pagination, version ownership, archival, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. + Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. The database setup follows this lifecycle: diff --git a/hub/schema/access-effect.enum.prisma b/hub/schema/access-effect.enum.prisma new file mode 100644 index 00000000..255e5a9e --- /dev/null +++ b/hub/schema/access-effect.enum.prisma @@ -0,0 +1,4 @@ +enum AccessEffect { + allow + deny +} diff --git a/hub/schema/migrations/20261027220000_prototype_authorization/migration.sql b/hub/schema/migrations/20261027220000_prototype_authorization/migration.sql new file mode 100644 index 00000000..c8321554 --- /dev/null +++ b/hub/schema/migrations/20261027220000_prototype_authorization/migration.sql @@ -0,0 +1,109 @@ +-- CreateEnum +CREATE TYPE "PrototypeRole" AS ENUM ('viewer', 'commenter', 'editor', 'administrator'); + +-- CreateEnum +CREATE TYPE "AccessEffect" AS ENUM ('allow', 'deny'); + +-- CreateTable +CREATE TABLE "workspaces" ( + "id" UUID NOT NULL, + "created_by_user_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "prototypes" ( + "id" UUID NOT NULL, + "workspace_id" UUID NOT NULL, + "created_by_user_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "archived_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "prototypes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "prototype_versions" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "created_by_user_id" UUID NOT NULL, + "sequence" INTEGER NOT NULL, + "label" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "prototype_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspace_access_grants" ( + "id" UUID NOT NULL, + "workspace_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "effect" "AccessEffect" NOT NULL, + "role" "PrototypeRole", + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_access_grants_pkey" PRIMARY KEY ("id"), + CONSTRAINT "workspace_access_grants_effect_role" CHECK ( + ("effect" = 'deny' AND "role" IS NULL) OR + ("effect" = 'allow' AND "role" IS NOT NULL) + ) +); + +-- CreateTable +CREATE TABLE "prototype_access_grants" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "effect" "AccessEffect" NOT NULL, + "role" "PrototypeRole", + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "prototype_access_grants_pkey" PRIMARY KEY ("id"), + CONSTRAINT "prototype_access_grants_effect_role" CHECK ( + ("effect" = 'deny' AND "role" IS NULL) OR + ("effect" = 'allow' AND "role" IS NOT NULL) + ) +); + +-- AlterTable +ALTER TABLE "security_audits" +ADD COLUMN "workspace_id" UUID, +ADD COLUMN "prototype_id" UUID, +ADD COLUMN "prototype_version_id" UUID, +ADD COLUMN "target_user_id" UUID; + +-- CreateIndex +CREATE INDEX "workspaces_created_by_user_id_idx" ON "workspaces"("created_by_user_id"); +CREATE INDEX "prototypes_workspace_id_idx" ON "prototypes"("workspace_id"); +CREATE INDEX "prototypes_created_by_user_id_idx" ON "prototypes"("created_by_user_id"); +CREATE UNIQUE INDEX "prototype_versions_prototype_id_sequence_key" ON "prototype_versions"("prototype_id", "sequence"); +CREATE INDEX "prototype_versions_created_by_user_id_idx" ON "prototype_versions"("created_by_user_id"); +CREATE UNIQUE INDEX "workspace_access_grants_workspace_id_user_id_key" ON "workspace_access_grants"("workspace_id", "user_id"); +CREATE INDEX "workspace_access_grants_user_id_idx" ON "workspace_access_grants"("user_id"); +CREATE UNIQUE INDEX "prototype_access_grants_prototype_id_user_id_key" ON "prototype_access_grants"("prototype_id", "user_id"); +CREATE INDEX "prototype_access_grants_user_id_idx" ON "prototype_access_grants"("user_id"); +CREATE INDEX "security_audits_workspace_id_idx" ON "security_audits"("workspace_id"); +CREATE INDEX "security_audits_prototype_id_idx" ON "security_audits"("prototype_id"); +CREATE INDEX "security_audits_prototype_version_id_idx" ON "security_audits"("prototype_version_id"); +CREATE INDEX "security_audits_target_user_id_idx" ON "security_audits"("target_user_id"); + +-- AddForeignKey +ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_versions" ADD CONSTRAINT "prototype_versions_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_versions" ADD CONSTRAINT "prototype_versions_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "workspace_access_grants" ADD CONSTRAINT "workspace_access_grants_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "workspace_access_grants" ADD CONSTRAINT "workspace_access_grants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_access_grants" ADD CONSTRAINT "prototype_access_grants_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "prototype_access_grants" ADD CONSTRAINT "prototype_access_grants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/hub/schema/prototype-access-grant.prisma b/hub/schema/prototype-access-grant.prisma new file mode 100644 index 00000000..8aecee23 --- /dev/null +++ b/hub/schema/prototype-access-grant.prisma @@ -0,0 +1,22 @@ +model PrototypeAccessGrant { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + // Attribute columns + effect AccessEffect + role PrototypeRole? + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([prototypeId, userId]) + @@index([userId]) + @@map("prototype_access_grants") +} diff --git a/hub/schema/prototype-role.enum.prisma b/hub/schema/prototype-role.enum.prisma new file mode 100644 index 00000000..41aedcd3 --- /dev/null +++ b/hub/schema/prototype-role.enum.prisma @@ -0,0 +1,6 @@ +enum PrototypeRole { + viewer + commenter + editor + administrator +} diff --git a/hub/schema/prototype-version.prisma b/hub/schema/prototype-version.prisma new file mode 100644 index 00000000..8a60193d --- /dev/null +++ b/hub/schema/prototype-version.prisma @@ -0,0 +1,22 @@ +model PrototypeVersion { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + createdByUserId String @map("created_by_user_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + createdBy User @relation("PrototypeVersionCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + + // Attribute columns + sequence Int + label String? + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([prototypeId, sequence]) + @@index([createdByUserId]) + @@map("prototype_versions") +} diff --git a/hub/schema/prototype.prisma b/hub/schema/prototype.prisma new file mode 100644 index 00000000..fd2bdfd2 --- /dev/null +++ b/hub/schema/prototype.prisma @@ -0,0 +1,24 @@ +model Prototype { + // ID columns + id String @id @default(uuid()) @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid + createdByUserId String @map("created_by_user_id") @db.Uuid + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + createdBy User @relation("PrototypeCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + versions PrototypeVersion[] + grants PrototypeAccessGrant[] + + // Attribute columns + name String + archivedAt DateTime? @map("archived_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([workspaceId]) + @@index([createdByUserId]) + @@map("prototypes") +} diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma index e3bcd5ab..312de7fc 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -6,6 +6,10 @@ model SecurityAudit { authenticationProviderRevisionId String? @map("authentication_provider_revision_id") @db.Uuid targetMembershipId String? @map("target_membership_id") @db.Uuid invitationId String? @map("invitation_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + prototypeId String? @map("prototype_id") @db.Uuid + prototypeVersionId String? @map("prototype_version_id") @db.Uuid + targetUserId String? @map("target_user_id") @db.Uuid // Relations targetMembership InstallationMembership? @relation("TargetMembership", fields: [targetMembershipId], references: [id], onDelete: SetNull) @@ -23,5 +27,9 @@ model SecurityAudit { @@index([createdAt]) @@index([targetMembershipId]) @@index([invitationId]) + @@index([workspaceId]) + @@index([prototypeId]) + @@index([prototypeVersionId]) + @@index([targetUserId]) @@map("security_audits") } diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma index 8ff2cd32..3e9abf4f 100644 --- a/hub/schema/user.prisma +++ b/hub/schema/user.prisma @@ -7,6 +7,11 @@ model User { memberships InstallationMembership[] sessions Session[] createdInvitations MembershipInvitation[] @relation("InvitationCreator") + createdWorkspaces Workspace[] @relation("WorkspaceCreator") + createdPrototypes Prototype[] @relation("PrototypeCreator") + createdVersions PrototypeVersion[] @relation("PrototypeVersionCreator") + workspaceGrants WorkspaceAccessGrant[] + prototypeGrants PrototypeAccessGrant[] // Attribute columns displayName String? @map("display_name") diff --git a/hub/schema/workspace-access-grant.prisma b/hub/schema/workspace-access-grant.prisma new file mode 100644 index 00000000..dbeaeb84 --- /dev/null +++ b/hub/schema/workspace-access-grant.prisma @@ -0,0 +1,22 @@ +model WorkspaceAccessGrant { + // ID columns + id String @id @default(uuid()) @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + // Attribute columns + effect AccessEffect + role PrototypeRole? + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([workspaceId, userId]) + @@index([userId]) + @@map("workspace_access_grants") +} diff --git a/hub/schema/workspace.prisma b/hub/schema/workspace.prisma new file mode 100644 index 00000000..f9d87afb --- /dev/null +++ b/hub/schema/workspace.prisma @@ -0,0 +1,20 @@ +model Workspace { + // ID columns + id String @id @default(uuid()) @db.Uuid + createdByUserId String @map("created_by_user_id") @db.Uuid + + // Relations + createdBy User @relation("WorkspaceCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + prototypes Prototype[] + grants WorkspaceAccessGrant[] + + // Attribute columns + name String + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([createdByUserId]) + @@map("workspaces") +} diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index a78512b8..dcdb588d 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -17,7 +17,10 @@ export function createApp({ app.disable("x-powered-by") app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) - app.use(["/api/auth", "/api/session", "/api/administration"], omitRequestLogPayload) + app.use( + ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], + omitRequestLogPayload, + ) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 71ef0d1d..5084467e 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -4,6 +4,7 @@ import { createAuthenticationService } from "#server/domains/authentication/crea import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" import { createIdentityStore } from "#server/domains/identity/create-store" +import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" @@ -84,6 +85,7 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv } return { administration: createAdministrationService({ store }), + prototypeAuthorization: createPrototypeAuthorizationService({ database: prisma, store }), service: createAuthenticationService({ store, github: configuration ? createGitHubUserAuthClient(configuration) : null, diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts new file mode 100644 index 00000000..93a4bb8e --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createIdentityStore } from "#server/domains/identity/create-store" +import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { + prisma, + store: createIdentityStore(prisma), + service: createPrototypeAuthorizationService({ + database: prisma, + store: createIdentityStore(prisma), + }), + } +} + +async function createActor( + store: IdentityStore, + { + subject, + token, + role = "member", + }: { + subject: string + token: string + role?: "member" | "administrator" + }, +) { + const authorizedAt = new Date() + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `User ${subject}`, + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `user-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role, + source: "manual", + }) + const session = await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }) + return { membership, session, user } + }) +} + +describe("createPrototypeAuthorizationService() database behavior", () => { + it("separates installation authority from prototype access and explicitly grants a creator", async () => { + const { prisma, service, store } = await database() + const administrator = await createActor(store, { + subject: "administrator", + token: "administrator-token", + role: "administrator", + }) + const member = await createActor(store, { + subject: "member", + token: "member-token", + }) + const existingWorkspace = await prisma.workspace.create({ + data: { + name: "Existing workspace", + createdByUserId: member.user.id, + }, + }) + + await expect( + service.getWorkspace("administrator-token", existingWorkspace.id), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + await prisma.workspaceAccessGrant.create({ + data: { + workspaceId: existingWorkspace.id, + userId: member.user.id, + effect: "allow", + role: "viewer", + }, + }) + await expect(service.getWorkspace("member-token", existingWorkspace.id)).resolves.toMatchObject( + { + id: existingWorkspace.id, + }, + ) + + const created = (await service.createWorkspace("administrator-token", { + name: "Created workspace", + })) as { id: string } + + await expect(service.getWorkspace("administrator-token", created.id)).resolves.toMatchObject({ + id: created.id, + createdByUserId: administrator.user.id, + }) + await expect(service.getWorkspace("member-token", created.id)).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: created.id, + userId: administrator.user.id, + }, + }, + }), + ).toMatchObject({ + effect: "allow", + role: "administrator", + }) + }) + + it("isolates grants and immutable versions by prototype", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { subject: "isolated", token: "isolated-token" }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const [allowedPrototype, otherPrototype] = await Promise.all([ + prisma.prototype.create({ + data: { + name: "Allowed", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }), + prisma.prototype.create({ + data: { + name: "Other", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }), + ]) + const otherVersion = await prisma.prototypeVersion.create({ + data: { + prototypeId: otherPrototype.id, + createdByUserId: actor.user.id, + sequence: 1, + label: "Other version", + }, + }) + + await expect( + service.getPrototype("isolated-token", allowedPrototype.id), + ).resolves.toMatchObject({ + id: allowedPrototype.id, + }) + await expect(service.getPrototype("isolated-token", otherPrototype.id)).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + await expect( + service.getVersion("isolated-token", allowedPrototype.id, otherVersion.id), + ).rejects.toMatchObject({ + status: 404, + code: "version_not_found", + }) + }) + + it("rechecks membership in the mutation transaction instead of trusting a stale session", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "stale", + token: "stale-token", + role: "administrator", + }) + const target = await createActor(store, { subject: "stale-target", token: "target-token" }) + const workspace = (await service.createWorkspace("stale-token", { + name: "Workspace", + })) as { id: string } + + await store.lock((records) => + records.saveMembership({ + userId: actor.user.id, + status: "suspended", + role: "administrator", + source: "manual", + suspendedAt: new Date("2026-10-27T18:01:00.000Z"), + }), + ) + + await expect( + service.setWorkspaceAccess("stale-token", workspace.id, target.user.id, { + effect: "allow", + role: "viewer", + }), + ).rejects.toMatchObject({ + status: 401, + code: "membership_inactive", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: target.user.id, + }, + }, + }), + ).toBeNull() + expect( + await prisma.securityAudit.count({ + where: { targetUserId: target.user.id }, + }), + ).toBe(0) + }) + + it("commits one valid rule and an audit for each successful concurrent access change", async () => { + const { prisma, service, store } = await database() + const administrator = await createActor(store, { + subject: "concurrent-administrator", + token: "concurrent-administrator-token", + role: "administrator", + }) + const target = await createActor(store, { + subject: "concurrent-target", + token: "concurrent-target-token", + }) + const workspace = (await service.createWorkspace("concurrent-administrator-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype( + "concurrent-administrator-token", + workspace.id, + { name: "Prototype" }, + )) as { id: string } + + const results = await Promise.allSettled([ + service.setPrototypeAccess("concurrent-administrator-token", prototype.id, target.user.id, { + effect: "allow", + role: "editor", + }), + service.setPrototypeAccess("concurrent-administrator-token", prototype.id, target.user.id, { + effect: "deny", + }), + ]) + + const fulfilled = results.filter(({ status }) => status === "fulfilled") + const grant = await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: target.user.id, + }, + }, + }) + const audits = await prisma.securityAudit.findMany({ + where: { + prototypeId: prototype.id, + targetUserId: target.user.id, + }, + orderBy: { createdAt: "asc" }, + }) + + expect(fulfilled.length).toBeGreaterThan(0) + expect(grant).toMatchObject( + grant?.effect === "deny" + ? { effect: "deny", role: null } + : { effect: "allow", role: "editor" }, + ) + expect(audits).toHaveLength(fulfilled.length) + expect(audits.map(({ event }) => event).sort()).toEqual( + fulfilled.length === 2 + ? ["prototype.access.deny", "prototype.access.grant"] + : [grant?.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant"], + ) + expect(audits.every(({ actorUserId }) => actorUserId === administrator.user.id)).toBe(true) + }) + + it("rolls back an access change when its audit cannot be saved", async () => { + const { prisma, service, store } = await database() + await createActor(store, { + subject: "rollback-administrator", + token: "rollback-administrator-token", + role: "administrator", + }) + const target = await createActor(store, { + subject: "rollback-target", + token: "rollback-target-token", + }) + const workspace = (await service.createWorkspace("rollback-administrator-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype("rollback-administrator-token", workspace.id, { + name: "Prototype", + })) as { id: string } + + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_prototype_access_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = 'prototype.access.grant' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_prototype_access_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_prototype_access_audit(); + `) + try { + await expect( + service.setPrototypeAccess("rollback-administrator-token", prototype.id, target.user.id, { + effect: "allow", + role: "viewer", + }), + ).rejects.toThrow("audit unavailable") + } finally { + await prisma.$executeRawUnsafe( + "DROP TRIGGER reject_prototype_access_audit ON security_audits", + ) + await prisma.$executeRawUnsafe("DROP FUNCTION reject_prototype_access_audit()") + } + + expect( + await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: target.user.id, + }, + }, + }), + ).toBeNull() + expect( + await prisma.securityAudit.count({ + where: { + event: "prototype.access.grant", + prototypeId: prototype.id, + targetUserId: target.user.id, + }, + }), + ).toBe(0) + }) + + it("bounds pages at 100, follows the cursor, and rejects larger limits", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { subject: "pagination", token: "pagination-token" }) + const workspaces = await Promise.all( + Array.from({ length: 101 }, (_, index) => + prisma.workspace.create({ + data: { + name: `Workspace ${index.toString().padStart(3, "0")}`, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }), + ), + ) + + const first = (await service.listWorkspaces("pagination-token", { + limit: 100, + })) as { items: { id: string }[]; nextCursor: string | null } + const second = (await service.listWorkspaces("pagination-token", { + limit: 100, + cursor: first.nextCursor, + })) as { items: { id: string }[]; nextCursor: string | null } + + expect(first.items).toHaveLength(100) + expect(first.nextCursor).toBe(first.items.at(-1)?.id) + expect(second.items).toHaveLength(1) + expect(second.nextCursor).toBeNull() + expect(new Set([...first.items, ...second.items].map(({ id }) => id))).toEqual( + new Set(workspaces.map(({ id }) => id)), + ) + await expect(service.listWorkspaces("pagination-token", { limit: 101 })).rejects.toMatchObject({ + status: 422, + code: "invalid_pagination", + }) + }) + + it("creates sequential immutable versions and rejects additions after archival", async () => { + const { prisma, service, store } = await database() + await createActor(store, { + subject: "versions", + token: "versions-token", + role: "administrator", + }) + const workspace = (await service.createWorkspace("versions-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype("versions-token", workspace.id, { + name: "Prototype", + })) as { id: string } + + const created = await Promise.all([ + service.createVersion("versions-token", prototype.id, { label: "First request" }), + service.createVersion("versions-token", prototype.id, { label: "Second request" }), + ]) + + expect( + created + .map((version) => (version as { sequence: number }).sequence) + .sort((left, right) => left - right), + ).toEqual([1, 2]) + expect( + await prisma.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { sequence: "asc" }, + select: { sequence: true }, + }), + ).toEqual([{ sequence: 1 }, { sequence: 2 }]) + + await service.archivePrototype("versions-token", prototype.id) + await expect( + service.createVersion("versions-token", prototype.id, { label: "Too late" }), + ).rejects.toMatchObject({ + status: 409, + code: "prototype_archived", + }) + expect(await prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } })).toBe(2) + const audits = await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { createdAt: "asc" }, + select: { + event: true, + context: true, + prototypeVersionId: true, + }, + }) + expect(audits.map(({ event }) => event)).toEqual([ + "prototype.create", + "prototype.version.create", + "prototype.version.create", + "prototype.archive", + ]) + expect(audits.every(({ context }) => JSON.stringify(context) === "{}")).toBe(true) + expect(audits[0]?.prototypeVersionId).toBeNull() + expect(audits[1]?.prototypeVersionId).toEqual(expect.any(String)) + expect(audits[2]?.prototypeVersionId).toEqual(expect.any(String)) + expect(audits[3]?.prototypeVersionId).toBeNull() + }) +}) diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts new file mode 100644 index 00000000..de9ce98f --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -0,0 +1,588 @@ +import { z } from "zod" + +import { createIdentityRecords } from "#server/domains/identity/create-records" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import { + type AccessDecision, + type PrototypeRole, +} from "#server/domains/prototype-authorization/evaluate-access/types" +import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" +import type { + GrantInput, + PrototypeAuthorizationService, +} from "#server/domains/prototype-authorization/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { AuthenticationError } from "#server/utils/authentication-error" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +const nameSchema = z.object({ name: z.string().trim().min(1).max(120) }) +const versionSchema = z.object({ label: z.string().trim().min(1).max(120).nullable().optional() }) +const pageSchema = z.object({ + cursor: z.uuid().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), +}) +const grantSchema = z.discriminatedUnion("effect", [ + z.object({ effect: z.literal("deny") }), + z.object({ + effect: z.literal("allow"), + role: z.enum(["viewer", "commenter", "editor", "administrator"]), + }), +]) +const roles: PrototypeRole[] = ["viewer", "commenter", "editor", "administrator"] +const identifierSchema = z.uuid() + +function authenticationDependencies(store: IdentityStore) { + return { + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(false) }, + now: () => new Date(), + } +} + +function parsePage(query: unknown) { + const parsed = pageSchema.safeParse(query) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_pagination", "Pagination is invalid.") + } + return parsed.data +} + +function parseName(input: unknown, code: string, message: string) { + const parsed = nameSchema.safeParse(input) + if (!parsed.success) throw new AuthenticationError(422, code, message) + return parsed.data +} + +function parseGrant(input: unknown): GrantInput { + const parsed = grantSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_access_change", "The access change is invalid.") + } + return parsed.data.effect === "deny" + ? { effect: "deny", role: null } + : { effect: "allow", role: parsed.data.role } +} + +function hasRole(actual: PrototypeRole | null, required: PrototypeRole): boolean { + return actual !== null && roles.indexOf(actual) >= roles.indexOf(required) +} + +function requireIdentifier(value: string, code: string, message: string): void { + if (!identifierSchema.safeParse(value).success) { + throw new AuthenticationError(404, code, message) + } +} + +function requireDecision(decision: AccessDecision, required: PrototypeRole): void { + if (!decision.allowed || !hasRole(decision.role, required)) { + throw new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied.") + } +} + +function requireFreshAuthorization(actor: Awaited>): void { + if (!actor.freshAuthorizedAt || Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Verify your identity again.", + ) + } +} + +async function currentSession( + store: IdentityStore, + sessionToken: string, + database?: Prisma.TransactionClient, +) { + return getCurrentSession( + authenticationDependencies(store), + sessionToken, + database ? createIdentityRecords(database) : store.read, + ) +} + +async function requireActiveTarget(database: Prisma.TransactionClient, userId: string) { + if (!identifierSchema.safeParse(userId).success) { + throw new AuthenticationError(422, "target_user_inactive", "The target user is not active.") + } + const membership = await database.installationMembership.findUnique({ where: { userId } }) + if (!membership || membership.status !== "active") { + throw new AuthenticationError(422, "target_user_inactive", "The target user is not active.") + } +} + +async function workspaceDecision( + database: Prisma.TransactionClient, + userId: string, + workspaceId: string, +) { + requireIdentifier(workspaceId, "workspace_not_found", "The workspace was not found.") + const workspace = await database.workspace.findUnique({ + where: { id: workspaceId }, + include: { grants: { where: { userId } } }, + }) + if (!workspace) { + throw new AuthenticationError(404, "workspace_not_found", "The workspace was not found.") + } + return { + workspace, + decision: evaluatePrototypeAccess({ workspaceGrant: workspace.grants[0] ?? null }), + } +} + +async function prototypeDecision( + database: Prisma.TransactionClient, + userId: string, + prototypeId: string, +) { + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + const prototype = await database.prototype.findUnique({ + where: { id: prototypeId }, + include: { + grants: { where: { userId } }, + workspace: { include: { grants: { where: { userId } } } }, + }, + }) + if (!prototype) { + throw new AuthenticationError(404, "prototype_not_found", "The prototype was not found.") + } + return { + prototype, + decision: evaluatePrototypeAccess({ + workspaceGrant: prototype.workspace.grants[0] ?? null, + prototypeGrant: prototype.grants[0] ?? null, + }), + } +} + +async function hasPrototypeAdministratorAfter( + database: Prisma.TransactionClient, + prototypeId: string, + targetUserId: string, + replacement: GrantInput | null, +): Promise { + const prototype = await database.prototype.findUnique({ + where: { id: prototypeId }, + include: { grants: true, workspace: { include: { grants: true } } }, + }) + if (!prototype) return false + const userIds = new Set([ + ...prototype.workspace.grants.map(({ userId }) => userId), + ...prototype.grants.map(({ userId }) => userId), + targetUserId, + ]) + return [...userIds].some((userId) => { + const workspaceGrant = prototype.workspace.grants.find((grant) => grant.userId === userId) + const prototypeGrant = + userId === targetUserId + ? replacement + : prototype.grants.find((grant) => grant.userId === userId) + return evaluatePrototypeAccess({ workspaceGrant, prototypeGrant }).role === "administrator" + }) +} + +function pageResult(items: T[], limit: number) { + const hasMore = items.length > limit + const pageItems = hasMore ? items.slice(0, limit) : items + return { + items: pageItems, + nextCursor: hasMore ? (pageItems.at(-1)?.id ?? null) : null, + } +} + +async function audit( + database: Prisma.TransactionClient, + actor: Awaited>, + input: Omit< + Parameters[1], + "actorUserId" | "sessionId" | "authenticationProviderRevisionId" + >, +) { + await saveSecurityAudit(database, { + ...input, + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + }) +} + +export function createPrototypeAuthorizationService({ + database, + store, +}: { + database: PrismaClient + store: IdentityStore +}): PrototypeAuthorizationService { + return { + async listWorkspaces(sessionToken, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + const items = await database.workspace.findMany({ + where: { + grants: { + some: { userId: actor.userId, effect: "allow" }, + none: { userId: actor.userId, effect: "deny" }, + }, + }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + }) + return pageResult(items, limit) + }, + + async createWorkspace(sessionToken, input) { + const { name } = parseName(input, "invalid_workspace", "The workspace is invalid.") + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + if (actor.membership.role !== "administrator") { + throw new AuthenticationError( + 403, + "installation_administrator_required", + "Installation administrator access is required.", + ) + } + const workspace = await transaction.workspace.create({ + data: { + name, + createdByUserId: actor.userId, + grants: { + create: { + userId: actor.userId, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + await audit(transaction, actor, { + event: "workspace.create", + workspaceId: workspace.id, + context: { source: "installation_administrator" }, + }) + return workspace + }) + }, + + async getWorkspace(sessionToken, workspaceId) { + const actor = await currentSession(store, sessionToken) + const { workspace, decision } = await workspaceDecision(database, actor.userId, workspaceId) + requireDecision(decision, "viewer") + return { + id: workspace.id, + createdByUserId: workspace.createdByUserId, + name: workspace.name, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + } + }, + + async setWorkspaceAccess(sessionToken, workspaceId, targetUserId, input) { + const change = parseGrant(input) + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) + requireDecision(decision, "administrator") + await requireActiveTarget(transaction, targetUserId) + const existing = await transaction.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + }) + const removesAdministrator = + existing?.effect === "allow" && + existing.role === "administrator" && + (change.effect === "deny" || change.role !== "administrator") + if ( + removesAdministrator && + (await transaction.workspaceAccessGrant.count({ + where: { workspaceId, effect: "allow", role: "administrator" }, + })) <= 1 + ) { + throw new AuthenticationError( + 409, + "last_workspace_administrator", + "The final workspace administrator cannot be changed.", + ) + } + const grant = await transaction.workspaceAccessGrant.upsert({ + where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + create: { workspaceId, userId: targetUserId, ...change }, + update: change, + }) + await audit(transaction, actor, { + event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", + workspaceId, + targetUserId, + context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, + }) + return grant + }) + }, + + async removeWorkspaceAccess(sessionToken, workspaceId, targetUserId) { + await database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) + requireDecision(decision, "administrator") + requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") + const existing = await transaction.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + }) + if (!existing) { + throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") + } + if ( + existing.effect === "allow" && + existing.role === "administrator" && + (await transaction.workspaceAccessGrant.count({ + where: { workspaceId, effect: "allow", role: "administrator" }, + })) <= 1 + ) { + throw new AuthenticationError( + 409, + "last_workspace_administrator", + "The final workspace administrator cannot be removed.", + ) + } + await transaction.workspaceAccessGrant.delete({ + where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + }) + await audit(transaction, actor, { + event: "workspace.access.remove", + workspaceId, + targetUserId, + }) + }) + }, + + async listPrototypes(sessionToken, workspaceId, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + const { decision } = await workspaceDecision(database, actor.userId, workspaceId) + requireDecision(decision, "viewer") + const items = await database.prototype.findMany({ + where: { workspaceId }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + }) + return pageResult(items, limit) + }, + + async createPrototype(sessionToken, workspaceId, input) { + const { name } = parseName(input, "invalid_prototype", "The prototype is invalid.") + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) + requireDecision(decision, "editor") + const prototype = await transaction.prototype.create({ + data: { workspaceId, name, createdByUserId: actor.userId }, + }) + await audit(transaction, actor, { + event: "prototype.create", + workspaceId, + prototypeId: prototype.id, + }) + return prototype + }) + }, + + async getPrototype(sessionToken, prototypeId) { + const actor = await currentSession(store, sessionToken) + const { prototype, decision } = await prototypeDecision(database, actor.userId, prototypeId) + requireDecision(decision, "viewer") + return { + id: prototype.id, + workspaceId: prototype.workspaceId, + createdByUserId: prototype.createdByUserId, + name: prototype.name, + archivedAt: prototype.archivedAt, + createdAt: prototype.createdAt, + updatedAt: prototype.updatedAt, + } + }, + + async archivePrototype(sessionToken, prototypeId) { + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "editor") + if (prototype.archivedAt) { + throw new AuthenticationError( + 409, + "prototype_archived", + "The prototype is already archived.", + ) + } + const archived = await transaction.prototype.update({ + where: { id: prototypeId }, + data: { archivedAt: new Date() }, + }) + await audit(transaction, actor, { + event: "prototype.archive", + workspaceId: prototype.workspaceId, + prototypeId, + }) + return archived + }) + }, + + async explainPrototypeAccess(sessionToken, prototypeId) { + const actor = await currentSession(store, sessionToken) + const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + return decision + }, + + async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { + const change = parseGrant(input) + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "administrator") + await requireActiveTarget(transaction, targetUserId) + if ( + !(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetUserId, change)) + ) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "The final prototype administrator cannot be changed.", + ) + } + const grant = await transaction.prototypeAccessGrant.upsert({ + where: { prototypeId_userId: { prototypeId, userId: targetUserId } }, + create: { prototypeId, userId: targetUserId, ...change }, + update: change, + }) + await audit(transaction, actor, { + event: change.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant", + workspaceId: prototype.workspaceId, + prototypeId, + targetUserId, + context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, + }) + return grant + }) + }, + + async removePrototypeAccess(sessionToken, prototypeId, targetUserId) { + await database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "administrator") + requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") + if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetUserId, null))) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "The final prototype administrator cannot be removed.", + ) + } + const removed = await transaction.prototypeAccessGrant.deleteMany({ + where: { prototypeId, userId: targetUserId }, + }) + if (removed.count === 0) { + throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") + } + await audit(transaction, actor, { + event: "prototype.access.remove", + workspaceId: prototype.workspaceId, + prototypeId, + targetUserId, + }) + }) + }, + + async listVersions(sessionToken, prototypeId, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + requireDecision(decision, "viewer") + const items = await database.prototypeVersion.findMany({ + where: { prototypeId }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + }) + return pageResult(items, limit) + }, + + async createVersion(sessionToken, prototypeId, input) { + const parsed = versionSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_version", "The version is invalid.") + } + return database.$transaction(async (transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + const { prototype, decision } = await prototypeDecision( + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "editor") + if (prototype.archivedAt) { + throw new AuthenticationError( + 409, + "prototype_archived", + "Archived prototypes cannot receive versions.", + ) + } + await transaction.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` + const latest = await transaction.prototypeVersion.findFirst({ + where: { prototypeId }, + orderBy: { sequence: "desc" }, + }) + const version = await transaction.prototypeVersion.create({ + data: { + prototypeId, + createdByUserId: actor.userId, + sequence: (latest?.sequence ?? 0) + 1, + label: parsed.data.label ?? null, + }, + }) + await audit(transaction, actor, { + event: "prototype.version.create", + workspaceId: prototype.workspaceId, + prototypeId, + prototypeVersionId: version.id, + }) + return version + }) + }, + + async getVersion(sessionToken, prototypeId, versionId) { + const actor = await currentSession(store, sessionToken) + const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + requireDecision(decision, "viewer") + requireIdentifier(versionId, "version_not_found", "The version was not found.") + const version = await database.prototypeVersion.findFirst({ + where: { id: versionId, prototypeId }, + }) + if (!version) { + throw new AuthenticationError(404, "version_not_found", "The version was not found.") + } + return version + }, + } +} diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts new file mode 100644 index 00000000..20f044af --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -0,0 +1,47 @@ +import type { AccessDecision, PrototypeRole } from "../evaluate-access/types.ts" + +export interface Page { + items: T[] + nextCursor: string | null +} + +export interface PrototypeAuthorizationService { + listWorkspaces(sessionToken: string, query: unknown): Promise> + createWorkspace(sessionToken: string, input: unknown): Promise + getWorkspace(sessionToken: string, workspaceId: string): Promise + setWorkspaceAccess( + sessionToken: string, + workspaceId: string, + targetUserId: string, + input: unknown, + ): Promise + removeWorkspaceAccess( + sessionToken: string, + workspaceId: string, + targetUserId: string, + ): Promise + listPrototypes(sessionToken: string, workspaceId: string, query: unknown): Promise> + createPrototype(sessionToken: string, workspaceId: string, input: unknown): Promise + getPrototype(sessionToken: string, prototypeId: string): Promise + archivePrototype(sessionToken: string, prototypeId: string): Promise + explainPrototypeAccess(sessionToken: string, prototypeId: string): Promise + setPrototypeAccess( + sessionToken: string, + prototypeId: string, + targetUserId: string, + input: unknown, + ): Promise + removePrototypeAccess( + sessionToken: string, + prototypeId: string, + targetUserId: string, + ): Promise + listVersions(sessionToken: string, prototypeId: string, query: unknown): Promise> + createVersion(sessionToken: string, prototypeId: string, input: unknown): Promise + getVersion(sessionToken: string, prototypeId: string, versionId: string): Promise +} + +export interface GrantInput { + effect: "allow" | "deny" + role: PrototypeRole | null +} diff --git a/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts new file mode 100644 index 00000000..e3fab4a9 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest" + +import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" + +describe("evaluatePrototypeAccess()", () => { + it("uses a prototype role even when it restricts a stronger workspace role", () => { + expect( + evaluatePrototypeAccess({ + workspaceGrant: { effect: "allow", role: "administrator" }, + prototypeGrant: { effect: "allow", role: "viewer" }, + baselineRole: "editor", + }), + ).toEqual({ + allowed: true, + role: "viewer", + source: "hub-prototype", + reason: "prototype_role", + }) + }) + + it.each([ + { + name: "prototype deny", + workspaceGrant: { effect: "allow", role: "administrator" } as const, + prototypeGrant: { effect: "deny", role: null } as const, + source: "hub-prototype", + }, + { + name: "workspace deny", + workspaceGrant: { effect: "deny", role: null } as const, + prototypeGrant: { effect: "allow", role: "administrator" } as const, + source: "hub-workspace", + }, + ])("lets an explicit $name override every allow source", (input) => { + expect( + evaluatePrototypeAccess({ + workspaceGrant: input.workspaceGrant, + prototypeGrant: input.prototypeGrant, + baselineRole: "administrator", + }), + ).toEqual({ + allowed: false, + role: null, + source: input.source, + reason: "explicit_deny", + }) + }) + + it("orders workspace access before a future baseline", () => { + expect( + evaluatePrototypeAccess({ + workspaceGrant: { effect: "allow", role: "commenter" }, + baselineRole: "administrator", + }), + ).toEqual({ + allowed: true, + role: "commenter", + source: "hub-workspace", + reason: "workspace_role", + }) + + expect(evaluatePrototypeAccess({ baselineRole: "editor" })).toEqual({ + allowed: true, + role: "editor", + source: "baseline", + reason: "baseline_role", + }) + }) + + it("defaults to an explainable denial when no source grants access", () => { + expect(evaluatePrototypeAccess({})).toEqual({ + allowed: false, + role: null, + source: "none", + reason: "no_access", + }) + }) +}) diff --git a/hub/server/domains/prototype-authorization/evaluate-access/index.ts b/hub/server/domains/prototype-authorization/evaluate-access/index.ts new file mode 100644 index 00000000..2d258e26 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.ts @@ -0,0 +1,49 @@ +import type { + AccessDecision, + AccessInput, +} from "#server/domains/prototype-authorization/evaluate-access/types" + +export function evaluatePrototypeAccess({ + workspaceGrant, + prototypeGrant, + baselineRole = null, +}: AccessInput): AccessDecision { + if (prototypeGrant?.effect === "deny" || workspaceGrant?.effect === "deny") { + return { + allowed: false, + role: null, + source: prototypeGrant?.effect === "deny" ? "hub-prototype" : "hub-workspace", + reason: "explicit_deny", + } + } + if (prototypeGrant?.effect === "allow" && prototypeGrant.role) { + return { + allowed: true, + role: prototypeGrant.role, + source: "hub-prototype", + reason: "prototype_role", + } + } + if (workspaceGrant?.effect === "allow" && workspaceGrant.role) { + return { + allowed: true, + role: workspaceGrant.role, + source: "hub-workspace", + reason: "workspace_role", + } + } + if (baselineRole) { + return { + allowed: true, + role: baselineRole, + source: "baseline", + reason: "baseline_role", + } + } + return { + allowed: false, + role: null, + source: "none", + reason: "no_access", + } +} diff --git a/hub/server/domains/prototype-authorization/evaluate-access/types.ts b/hub/server/domains/prototype-authorization/evaluate-access/types.ts new file mode 100644 index 00000000..0ce29321 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/types.ts @@ -0,0 +1,19 @@ +export type PrototypeRole = "viewer" | "commenter" | "editor" | "administrator" + +export interface AccessGrant { + effect: "allow" | "deny" + role: PrototypeRole | null +} + +export interface AccessDecision { + allowed: boolean + role: PrototypeRole | null + source: "hub-prototype" | "hub-workspace" | "baseline" | "none" + reason: "explicit_deny" | "prototype_role" | "workspace_role" | "baseline_role" | "no_access" +} + +export interface AccessInput { + workspaceGrant?: AccessGrant | null + prototypeGrant?: AccessGrant | null + baselineRole?: PrototypeRole | null +} diff --git a/hub/server/integrations/express/create-rate-limit/index.ts b/hub/server/integrations/express/create-rate-limit/index.ts index 5170ffd2..18eb7f5a 100644 --- a/hub/server/integrations/express/create-rate-limit/index.ts +++ b/hub/server/integrations/express/create-rate-limit/index.ts @@ -6,10 +6,12 @@ export function createRateLimit({ limit, windowMilliseconds, maxEntries = 10_000, + message = "Too many authentication requests.", }: { limit: number windowMilliseconds: number maxEntries?: number + message?: string }) { const entries = new Map() @@ -29,7 +31,7 @@ export function createRateLimit({ entry.count++ entries.set(key, entry) if (entry.count > limit) { - next(new AuthenticationError(429, "rate_limited", "Too many authentication requests.")) + next(new AuthenticationError(429, "rate_limited", message)) return } next() diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts index b67c6235..6b63ca08 100644 --- a/hub/server/repositories/security-audits/save-audit/index.ts +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -24,6 +24,10 @@ export async function saveSecurityAudit(database: Prisma.TransactionClient, data outcome: data.outcome ?? "succeeded", targetMembershipId: data.targetMembershipId ?? null, invitationId: data.invitationId ?? null, + workspaceId: data.workspaceId ?? null, + prototypeId: data.prototypeId ?? null, + prototypeVersionId: data.prototypeVersionId ?? null, + targetUserId: data.targetUserId ?? null, context, }, }) diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index 7283d69c..fe15ab02 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -6,6 +6,10 @@ export interface AuditInput { authenticationProviderRevisionId: string | null targetMembershipId?: string | null invitationId?: string | null + workspaceId?: string | null + prototypeId?: string | null + prototypeVersionId?: string | null + targetUserId?: string | null context?: Record } @@ -18,6 +22,10 @@ export interface SecurityAuditRecord { authenticationProviderRevisionId: string | null targetMembershipId: string | null invitationId: string | null + workspaceId: string | null + prototypeId: string | null + prototypeVersionId: string | null + targetUserId: string | null context: unknown createdAt: Date } diff --git a/hub/server/routes/administration.test.ts b/hub/server/routes/administration.test.ts index dc8db20e..60dcecec 100644 --- a/hub/server/routes/administration.test.ts +++ b/hub/server/routes/administration.test.ts @@ -6,6 +6,7 @@ import type { Mocked } from "vitest" import type { AdministrationService } from "#server/domains/administration/create-service/types" import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" import type { AuthenticationRoutes } from "#server/routes/types" import { AuthenticationError } from "#server/utils/authentication-error" @@ -45,6 +46,7 @@ function runtime( return { service, administration, + prototypeAuthorization: {} as PrototypeAuthorizationService, settings: { origin: "https://hub.example", secureCookies: true, diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index 72f1dd04..83015cdc 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import type { Mocked } from "vitest" import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" import type { AuthenticationRoutes } from "#server/routes/types" import { createLogger } from "#server/integrations/pino/create-logger" import { AuthenticationError } from "#server/utils/authentication-error" @@ -55,6 +56,7 @@ function authentication( }, service, administration, + prototypeAuthorization: {} as PrototypeAuthorizationService, } } diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index e33dda0c..f9ad0ddd 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -16,6 +16,23 @@ import { delete as handleApiSessionDelete } from "#server/routes/api/session/del import { get as handleApiSessionGet } from "#server/routes/api/session/get" import { post as handleApiSessionRevokeAllPost } from "#server/routes/api/session/revoke-all/post" import { post as handleApiSessionVerifyPost } from "#server/routes/api/session/verify/post" +import { + archivePrototype, + createPrototype, + createVersion, + createWorkspace, + explainPrototypeAccess, + getPrototype, + getVersion, + getWorkspace, + listPrototypes, + listVersions, + listWorkspaces, + removePrototypeAccess, + removeWorkspaceAccess, + setPrototypeAccess, + setWorkspaceAccess, +} from "#server/routes/product" import type { AuthenticationRoutes } from "#server/routes/types" /** Keep endpoint registration separate from the app's shared middleware. */ @@ -24,7 +41,7 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi if (!authentication) return app.use( - ["/api/auth", "/api/session", "/api/administration"], + ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], prepareAuthenticationRequest.bind(null, authentication.settings.origin), ) const initiationLimit = createRateLimit({ @@ -73,5 +90,57 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi handleApiAdministrationMembershipsPatch.bind(null, authentication), ) app.use("/api/administration", respondAdministrationError) + const productMutationLimit = createRateLimit({ + limit: 120, + windowMilliseconds: 5 * 60_000, + message: "Too many product requests.", + }) + app.get("/api/workspaces", listWorkspaces.bind(null, authentication)) + app.post("/api/workspaces", productMutationLimit, createWorkspace.bind(null, authentication)) + app.get("/api/workspaces/:workspaceId", getWorkspace.bind(null, authentication)) + app.put( + "/api/workspaces/:workspaceId/access/:userId", + productMutationLimit, + setWorkspaceAccess.bind(null, authentication), + ) + app.delete( + "/api/workspaces/:workspaceId/access/:userId", + productMutationLimit, + removeWorkspaceAccess.bind(null, authentication), + ) + app.get("/api/workspaces/:workspaceId/prototypes", listPrototypes.bind(null, authentication)) + app.post( + "/api/workspaces/:workspaceId/prototypes", + productMutationLimit, + createPrototype.bind(null, authentication), + ) + app.get("/api/prototypes/:prototypeId", getPrototype.bind(null, authentication)) + app.post( + "/api/prototypes/:prototypeId/archive", + productMutationLimit, + archivePrototype.bind(null, authentication), + ) + app.get( + "/api/prototypes/:prototypeId/access/explanation", + explainPrototypeAccess.bind(null, authentication), + ) + app.put( + "/api/prototypes/:prototypeId/access/:userId", + productMutationLimit, + setPrototypeAccess.bind(null, authentication), + ) + app.delete( + "/api/prototypes/:prototypeId/access/:userId", + productMutationLimit, + removePrototypeAccess.bind(null, authentication), + ) + app.get("/api/prototypes/:prototypeId/versions", listVersions.bind(null, authentication)) + app.post( + "/api/prototypes/:prototypeId/versions", + productMutationLimit, + createVersion.bind(null, authentication), + ) + app.get("/api/prototypes/:prototypeId/versions/:versionId", getVersion.bind(null, authentication)) + app.use(["/api/workspaces", "/api/prototypes"], respondAdministrationError) app.use(respondAuthenticationError) } diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts new file mode 100644 index 00000000..a8e195d0 --- /dev/null +++ b/hub/server/routes/product/index.test.ts @@ -0,0 +1,252 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" +import { createLogger } from "#server/integrations/pino/create-logger" +import type { AuthenticationRoutes } from "#server/routes/types" +import { AuthenticationError } from "#server/utils/authentication-error" + +import { createApp } from "../../create-app/index.ts" + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const protectedHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "content-type": "application/json", + "x-csrf-token": csrfToken, +} + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { + prototypeAuthorization: Mocked +} { + const prototypeAuthorization = Object.assign( + { + listWorkspaces: vi.fn(), + createWorkspace: vi.fn(), + getWorkspace: vi.fn(), + setWorkspaceAccess: vi.fn(), + removeWorkspaceAccess: vi.fn(), + listPrototypes: vi.fn(), + createPrototype: vi.fn(), + getPrototype: vi.fn(), + archivePrototype: vi.fn(), + explainPrototypeAccess: vi.fn(), + setPrototypeAccess: vi.fn(), + removePrototypeAccess: vi.fn(), + listVersions: vi.fn(), + createVersion: vi.fn(), + getVersion: vi.fn(), + }, + overrides, + ) as Mocked + return { + service: {} as Mocked, + administration: {} as Mocked, + prototypeAuthorization, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes, logger = createLogger()) { + const server = createServer(createApp({ authentication, logger })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe("product routes", () => { + it.each([ + { method: "GET", path: "/api/workspaces", call: "listWorkspaces" }, + { method: "POST", path: "/api/workspaces", call: "createWorkspace", body: { name: "W" } }, + { method: "GET", path: "/api/workspaces/workspace", call: "getWorkspace" }, + { + method: "PUT", + path: "/api/workspaces/workspace/access/user", + call: "setWorkspaceAccess", + body: { effect: "allow", role: "viewer" }, + }, + { + method: "DELETE", + path: "/api/workspaces/workspace/access/user", + call: "removeWorkspaceAccess", + }, + { + method: "GET", + path: "/api/workspaces/workspace/prototypes", + call: "listPrototypes", + }, + { + method: "POST", + path: "/api/workspaces/workspace/prototypes", + call: "createPrototype", + body: { name: "P" }, + }, + { method: "GET", path: "/api/prototypes/prototype", call: "getPrototype" }, + { + method: "POST", + path: "/api/prototypes/prototype/archive", + call: "archivePrototype", + }, + { + method: "GET", + path: "/api/prototypes/prototype/access/explanation", + call: "explainPrototypeAccess", + }, + { + method: "PUT", + path: "/api/prototypes/prototype/access/user", + call: "setPrototypeAccess", + body: { effect: "deny" }, + }, + { + method: "DELETE", + path: "/api/prototypes/prototype/access/user", + call: "removePrototypeAccess", + }, + { + method: "GET", + path: "/api/prototypes/prototype/versions", + call: "listVersions", + }, + { + method: "POST", + path: "/api/prototypes/prototype/versions", + call: "createVersion", + body: { label: "Version" }, + }, + { + method: "GET", + path: "/api/prototypes/prototype/versions/version", + call: "getVersion", + }, + ] as const)( + "preserves authorization failures for $method $path", + async ({ method, path, call, body }) => { + const authentication = runtime({ + [call]: vi + .fn() + .mockRejectedValue( + new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied."), + ), + }) + const baseUrl = await serve(authentication) + const mutation = method !== "GET" + + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: mutation ? protectedHeaders : { cookie: `__Host-replica-session=${sessionToken}` }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "prototype_access_denied", + message: "Prototype access is denied.", + }) + expect(authentication.prototypeAuthorization[call].mock.calls).toHaveLength(1) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }, + ) + + it.each([ + { + name: "missing origin", + headers: { ...protectedHeaders, origin: undefined }, + }, + { + name: "wrong origin", + headers: { ...protectedHeaders, origin: "https://attacker.example" }, + }, + { + name: "wrong CSRF token", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + }, + ])("rejects a mutation with $name before the product service runs", async ({ headers }) => { + const authentication = runtime() + const baseUrl = await serve(authentication) + const requestHeaders = Object.fromEntries( + Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + + const response = await fetch(`${baseUrl}/api/workspaces`, { + method: "POST", + headers: requestHeaders, + body: JSON.stringify({ name: "Private workspace name" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.prototypeAuthorization.createWorkspace.mock.calls).toHaveLength(0) + }) + + it("omits rejected product query and body values from request logs", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const authentication = runtime() + const baseUrl = await serve(authentication, logger) + + const response = await fetch(`${baseUrl}/api/workspaces?note=private-query-value`, { + method: "POST", + headers: { ...protectedHeaders, origin: "https://attacker.example" }, + body: JSON.stringify({ note: "private-body-value" }), + }) + + expect(response.status).toBe(403) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("query") + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toMatch(/private-query-value|private-body-value/) + }) + + it("allows 120 product mutations per boundary and rejects the next one", async () => { + const authentication = runtime({ + createWorkspace: vi.fn().mockResolvedValue({ id: "workspace" }), + }) + const baseUrl = await serve( + authentication, + createLogger({ destination: { write: () => undefined } }), + ) + const responses = [] + + for (let request = 0; request < 121; request++) { + responses.push( + await fetch(`${baseUrl}/api/workspaces`, { + method: "POST", + headers: protectedHeaders, + body: JSON.stringify({ name: `Workspace ${request}` }), + }), + ) + } + + expect(responses.slice(0, 120).every(({ status }) => status === 201)).toBe(true) + expect(responses[0]?.headers.get("cache-control")).toBe("private, no-store") + expect(responses[120]?.status).toBe(429) + expect(authentication.prototypeAuthorization.createWorkspace.mock.calls).toHaveLength(120) + }) +}) diff --git a/hub/server/routes/product/index.ts b/hub/server/routes/product/index.ts new file mode 100644 index 00000000..d3cac7a5 --- /dev/null +++ b/hub/server/routes/product/index.ts @@ -0,0 +1,219 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +function sessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + return readCookie(request.headers.cookie, cookies.session) +} + +function mutationSessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + return readCookie(request.headers.cookie, cookies.session) +} + +function parameter(request: Request, name: string): string { + const value = request.params[name] + return typeof value === "string" ? value : "" +} + +export async function listWorkspaces( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + response.json( + await authentication.prototypeAuthorization.listWorkspaces( + sessionToken(authentication, request), + request.query, + ), + ) +} + +export async function createWorkspace( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const workspace = await authentication.prototypeAuthorization.createWorkspace( + mutationSessionToken(authentication, request), + request.body, + ) + response.status(201).json({ workspace }) +} + +export async function getWorkspace( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const workspace = await authentication.prototypeAuthorization.getWorkspace( + sessionToken(authentication, request), + parameter(request, "workspaceId"), + ) + response.json({ workspace }) +} + +export async function setWorkspaceAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const access = await authentication.prototypeAuthorization.setWorkspaceAccess( + mutationSessionToken(authentication, request), + parameter(request, "workspaceId"), + parameter(request, "userId"), + request.body, + ) + response.json({ access }) +} + +export async function removeWorkspaceAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await authentication.prototypeAuthorization.removeWorkspaceAccess( + mutationSessionToken(authentication, request), + parameter(request, "workspaceId"), + parameter(request, "userId"), + ) + response.status(204).end() +} + +export async function listPrototypes( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + response.json( + await authentication.prototypeAuthorization.listPrototypes( + sessionToken(authentication, request), + parameter(request, "workspaceId"), + request.query, + ), + ) +} + +export async function createPrototype( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.createPrototype( + mutationSessionToken(authentication, request), + parameter(request, "workspaceId"), + request.body, + ) + response.status(201).json({ prototype }) +} + +export async function getPrototype( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.getPrototype( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + ) + response.json({ prototype }) +} + +export async function archivePrototype( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.archivePrototype( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + ) + response.json({ prototype }) +} + +export async function explainPrototypeAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const decision = await authentication.prototypeAuthorization.explainPrototypeAccess( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + ) + response.json({ decision }) +} + +export async function setPrototypeAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const access = await authentication.prototypeAuthorization.setPrototypeAccess( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "userId"), + request.body, + ) + response.json({ access }) +} + +export async function removePrototypeAccess( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await authentication.prototypeAuthorization.removePrototypeAccess( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "userId"), + ) + response.status(204).end() +} + +export async function listVersions( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + response.json( + await authentication.prototypeAuthorization.listVersions( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + request.query, + ), + ) +} + +export async function createVersion( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const version = await authentication.prototypeAuthorization.createVersion( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + request.body, + ) + response.status(201).json({ version }) +} + +export async function getVersion( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const version = await authentication.prototypeAuthorization.getVersion( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "versionId"), + ) + response.json({ version }) +} diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index 8a775590..b64ee8d9 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -3,9 +3,11 @@ import type { AuthenticationSettings, } from "#server/domains/authentication/create-service/types" import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" export interface AuthenticationRoutes { service: AuthenticationService administration: AdministrationService + prototypeAuthorization: PrototypeAuthorizationService settings: AuthenticationSettings } From 2fe3a49fcf476ebb4860dd0696b8a2ea640dca49 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 14:32:22 -0700 Subject: [PATCH 02/10] fix(hub): serialize prototype administration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/membership-and-administration.md | 2 + hub/docs/prototype-authorization.md | 8 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 170 +++++++ .../administration/create-service/index.ts | 5 +- .../create-service/index.test.ts | 2 +- .../create-service/membership.test.ts | 2 +- .../domains/identity/create-store/types.ts | 4 +- .../domains/identity/with-store-lock/index.ts | 2 +- .../create-service/index.db.test.ts | 455 ++++++++++++++++++ .../create-service/index.ts | 162 +++++-- .../ensure-administration-continuity/index.ts | 112 +++++ 12 files changed, 888 insertions(+), 38 deletions(-) create mode 100644 hub/server/domains/prototype-authorization/ensure-administration-continuity/index.ts diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md index eb6b4f3c..166c45bc 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -15,6 +15,8 @@ Each Hub user has at most one installation membership. Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. +Suspension and removal also check Hub-managed product administration in the same transaction. The change fails with `product_administration_orphaned` until another active user can administer every affected workspace and prototype. A role change from installation administrator to member does not remove explicit workspace or prototype roles. + ## Invitations An invitation targets an external identity assignment: diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 17e97e36..c96f6a09 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -10,7 +10,7 @@ A workspace owns prototypes. A prototype owns an ordered history of immutable ve - A workspace `editor` or `administrator` may create a prototype. - A prototype `editor` or `administrator` may create its next version. - A version cannot move to another prototype or be changed after creation. -- Archiving a prototype keeps its versions readable but prevents new versions. +- Archiving a prototype keeps its versions readable but prevents new versions. Archive and version creation lock and re-read the prototype so overlapping requests cannot add a version after archival. Artifact payloads are not part of this layer. A version identifies immutable prototype history without storing a build, manifest, or upload. @@ -39,7 +39,11 @@ The evaluator applies this order: Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. -Workspace administrators cannot remove or replace the final direct workspace administrator. A prototype access change must also leave at least one effective prototype administrator. A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. +Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. + +Suspending or removing a membership fails with `product_administration_orphaned` when that user is the final active administrator for a workspace or prototype. An administrator must transfer product administration first. Changing an active installation administrator to a member does not remove an explicit product role, because installation and product authority remain separate. + +A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. ## API and request boundaries diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 0a80f046..253b4b98 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes 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, overlapping grant changes, audit rollback, route enforcement, bounded pagination, version ownership, archival, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, deny-aware pagination, version ownership, archive/version races, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index 3eed6153..f6dfdb29 100644 --- a/hub/server/domains/administration/create-service/index.db.test.ts +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -464,4 +464,174 @@ describe("createAdministrationService() database behavior", () => { true, ) }) + + it("allows installation role demotion but prevents workspace orphaning on suspension", async () => { + const { prisma, store } = await database() + const actor = await createAdministrator(store, "workspace-actor", "workspace-actor-token") + const target = await createAdministrator(store, "workspace-target", "workspace-target-token") + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: target.user.id, + grants: { + create: { + userId: target.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("workspace-actor-token", target.membership.id, { + role: "member", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + role: "member", + status: "active", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ effect: "allow", role: "administrator" }) + + await expect( + service.changeMembership("workspace-actor-token", target.membership.id, { + status: "suspended", + }), + ).rejects.toMatchObject({ + status: 409, + code: "product_administration_orphaned", + message: "Transfer workspace administration before changing this membership.", + }) + + await prisma.workspaceAccessGrant.create({ + data: { + workspaceId: workspace.id, + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }) + await expect( + service.changeMembership("workspace-actor-token", target.membership.id, { + status: "suspended", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + role: "member", + status: "suspended", + }) + expect(await store.read.session(digestSecret("workspace-target-token"))).toBeNull() + const targetAudits = (await store.read.audits()).filter(({ targetMembershipId }) => { + return targetMembershipId === target.membership.id + }) + expect(targetAudits.every(({ event }) => event === "membership.change")).toBe(true) + expect( + targetAudits.map(({ context }) => (context as { reason: string }).reason).sort(), + ).toEqual(["role_member", "status_suspended"]) + }) + + it("prevents prototype orphaning on removal until an active replacement is assigned", async () => { + const { prisma, store } = await database() + const actor = await createAdministrator(store, "prototype-actor", "prototype-actor-token") + const target = await createAdministrator(store, "prototype-target", "prototype-target-token") + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + { + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + { + userId: target.user.id, + effect: "allow", + role: "administrator", + }, + ], + }, + }, + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("prototype-actor-token", target.membership.id, { + status: "removed", + }), + ).rejects.toMatchObject({ + status: 409, + code: "product_administration_orphaned", + message: "Transfer prototype administration before changing this membership.", + }) + + await prisma.prototypeAccessGrant.update({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: actor.user.id, + }, + }, + data: { role: "administrator" }, + }) + await expect( + service.changeMembership("prototype-actor-token", target.membership.id, { + status: "removed", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + status: "removed", + }) + expect(await store.read.session(digestSecret("prototype-target-token"))).toBeNull() + expect( + await prisma.securityAudit.findMany({ + where: { targetMembershipId: target.membership.id }, + select: { event: true, context: true }, + }), + ).toEqual([ + { + event: "membership.change", + context: { reason: "status_removed" }, + }, + ]) + }) }) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index 809df37f..92538492 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -272,7 +272,7 @@ export function createAdministrationService( "The membership was not found.", ) } - return dependencies.store.lock(async (records) => { + return dependencies.store.lock(async (records, database) => { const actor = await getLockedAdministrator(dependencies, sessionToken, records) const target = await records.membershipById(parsedId.data) if (!target) { @@ -309,6 +309,9 @@ export function createAdministrationService( "The final active administrator cannot be changed.", ) } + if (target.status === "active" && nextStatus !== "active") { + await ensureProductAdministrationContinuity(database, target.userId) + } const now = dependencies.now() const changed = await records.saveMembership({ userId: target.userId, diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index 28f01d40..6ff60b90 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -74,7 +74,7 @@ function store(identityRecords: TestIdentityRecords): IdentityStore { const typedRecords = identityRecords as unknown as IdentityRecords return { read: typedRecords, - lock: (operation) => operation(typedRecords), + lock: (operation) => operation(typedRecords, {} as never), } } diff --git a/hub/server/domains/authentication/create-service/membership.test.ts b/hub/server/domains/authentication/create-service/membership.test.ts index 7f0f942e..720d7e4d 100644 --- a/hub/server/domains/authentication/create-service/membership.test.ts +++ b/hub/server/domains/authentication/create-service/membership.test.ts @@ -56,7 +56,7 @@ describe("createAuthenticationService() membership enforcement", () => { } as unknown as IdentityRecords const store: IdentityStore = { read: records, - lock: (operation) => operation(records), + lock: (operation) => operation(records, {} as never), } const service = createAuthenticationService({ store, diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 6b9a64aa..3f4770d1 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -128,5 +128,7 @@ export interface IdentityQueries { export interface IdentityStore { read: IdentityQueries - lock(operation: (records: IdentityRecords) => Promise): Promise + lock( + operation: (records: IdentityRecords, database: Prisma.TransactionClient) => Promise, + ): Promise } diff --git a/hub/server/domains/identity/with-store-lock/index.ts b/hub/server/domains/identity/with-store-lock/index.ts index 42e8665a..02cd89ef 100644 --- a/hub/server/domains/identity/with-store-lock/index.ts +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -11,6 +11,6 @@ export function withIdentityStoreLock( ): Promise { return withRepositoryLock(client, async (database) => { await ensureGitHubIntegrationCredentials(database) - return operation(createIdentityRecords(database)) + return operation(createIdentityRecords(database), database) }) } diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 93a4bb8e..41095980 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -4,6 +4,8 @@ import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" import { digestSecret } from "#server/utils/digest-secret" +import type { PrismaClient } from "../../../generated/prisma/client.ts" + type IdentityStore = ReturnType async function database() { @@ -75,6 +77,62 @@ async function createActor( }) } +async function waitForBlockedRowLock(database: PrismaClient, table: string) { + const queryPattern = `%${table}%FOR UPDATE%` + await vi.waitFor( + async () => { + const [result] = await database.$queryRaw<{ blocked: boolean }[]>` + SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' + AND query LIKE ${queryPattern} + ) AS blocked + ` + expect(result?.blocked).toBe(true) + }, + { interval: 20, timeout: 5_000 }, + ) +} + +function deferred() { + let resolvePromise!: () => void + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} + +async function holdWorkspaceLock(database: PrismaClient, workspaceId: string) { + const released = deferred() + const locked = deferred() + const holder = database.$transaction(async (transaction) => { + await transaction.$queryRaw` + SELECT "id" FROM "workspaces" WHERE "id" = ${workspaceId}::uuid FOR UPDATE + ` + locked.resolve() + await released.promise + }) + await locked.promise + return { holder, release: released.resolve } +} + +async function holdPrototypeLock(database: PrismaClient, prototypeId: string) { + const released = deferred() + const locked = deferred() + const holder = database.$transaction(async (transaction) => { + await transaction.$queryRaw` + SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE + ` + locked.resolve() + await released.promise + }) + await locked.promise + return { holder, release: released.resolve } +} + describe("createPrototypeAuthorizationService() database behavior", () => { it("separates installation authority from prototype access and explicitly grants a creator", async () => { const { prisma, service, store } = await database() @@ -247,6 +305,243 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ).toBe(0) }) + it("serializes concurrent workspace administrator removal and downgrade", async () => { + const { prisma, service, store } = await database() + const first = await createActor(store, { + subject: "workspace-administrator-one", + token: "workspace-administrator-one-token", + }) + const second = await createActor(store, { + subject: "workspace-administrator-two", + token: "workspace-administrator-two-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: first.user.id, + grants: { + create: [ + { + userId: first.user.id, + effect: "allow", + role: "administrator", + }, + { + userId: second.user.id, + effect: "allow", + role: "administrator", + }, + ], + }, + }, + }) + const lock = await holdWorkspaceLock(prisma, workspace.id) + + const removal = service.removeWorkspaceAccess( + "workspace-administrator-one-token", + workspace.id, + first.user.id, + ) + await waitForBlockedRowLock(prisma, "workspaces") + const downgrade = service.setWorkspaceAccess( + "workspace-administrator-two-token", + workspace.id, + second.user.id, + { effect: "allow", role: "viewer" }, + ) + lock.release() + await lock.holder + const results = await Promise.allSettled([removal, downgrade]) + + expect(results[0]).toMatchObject({ status: "fulfilled" }) + if (results[1]?.status !== "rejected") throw new Error("The downgrade should be rejected.") + expect(results[1].reason).toMatchObject({ + status: 409, + code: "last_workspace_administrator", + }) + expect( + await prisma.workspaceAccessGrant.findMany({ + where: { workspaceId: workspace.id, effect: "allow", role: "administrator" }, + select: { userId: true }, + }), + ).toEqual([{ userId: second.user.id }]) + expect( + await prisma.securityAudit.findMany({ + where: { workspaceId: workspace.id, event: { startsWith: "workspace.access." } }, + select: { event: true, targetUserId: true }, + }), + ).toEqual([{ event: "workspace.access.remove", targetUserId: first.user.id }]) + }) + + it("lets exactly one prototype-direct administrator relinquish authority", async () => { + const { prisma, service, store } = await database() + const first = await createActor(store, { + subject: "prototype-administrator-one", + token: "prototype-administrator-one-token", + }) + const second = await createActor(store, { + subject: "prototype-administrator-two", + token: "prototype-administrator-two-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: first.user.id, + grants: { + create: [ + { userId: first.user.id, effect: "allow", role: "viewer" }, + { userId: second.user.id, effect: "allow", role: "viewer" }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: first.user.id, + grants: { + create: [ + { userId: first.user.id, effect: "allow", role: "administrator" }, + { userId: second.user.id, effect: "allow", role: "administrator" }, + ], + }, + }, + }) + const lock = await holdPrototypeLock(prisma, prototype.id) + + const removal = service.removePrototypeAccess( + "prototype-administrator-one-token", + prototype.id, + first.user.id, + ) + await waitForBlockedRowLock(prisma, "prototypes") + const downgrade = service.setPrototypeAccess( + "prototype-administrator-two-token", + prototype.id, + second.user.id, + { effect: "allow", role: "viewer" }, + ) + lock.release() + await lock.holder + const results = await Promise.allSettled([removal, downgrade]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + if (results[1]?.status !== "rejected") throw new Error("The downgrade should be rejected.") + expect(results[1].reason).toMatchObject({ + status: 409, + code: "last_prototype_administrator", + }) + expect( + await prisma.prototypeAccessGrant.findMany({ + where: { prototypeId: prototype.id, effect: "allow", role: "administrator" }, + select: { userId: true }, + }), + ).toEqual([{ userId: second.user.id }]) + expect( + await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id, event: { startsWith: "prototype.access." } }, + select: { event: true, targetUserId: true }, + }), + ).toEqual([{ event: "prototype.access.remove", targetUserId: first.user.id }]) + }) + + it("does not count suspended administrators in workspace or prototype continuity", async () => { + const { prisma, service, store } = await database() + const active = await createActor(store, { + subject: "active-product-administrator", + token: "active-product-administrator-token", + }) + const suspended = await createActor(store, { + subject: "suspended-product-administrator", + token: "suspended-product-administrator-token", + }) + const removed = await createActor(store, { + subject: "removed-product-administrator", + token: "removed-product-administrator-token", + }) + await store.lock((records) => + records.saveMembership({ + userId: suspended.user.id, + status: "suspended", + role: "member", + source: "manual", + suspendedAt: new Date(), + }), + ) + await store.lock((records) => + records.saveMembership({ + userId: removed.user.id, + status: "removed", + role: "member", + source: "manual", + removedAt: new Date(), + }), + ) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: active.user.id, + grants: { + create: [ + { userId: active.user.id, effect: "allow", role: "administrator" }, + { userId: suspended.user.id, effect: "allow", role: "administrator" }, + { userId: removed.user.id, effect: "allow", role: "administrator" }, + ], + }, + }, + }) + const prototypeWorkspace = await prisma.workspace.create({ + data: { + name: "Prototype workspace", + createdByUserId: active.user.id, + grants: { + create: [ + { userId: active.user.id, effect: "allow", role: "viewer" }, + { userId: suspended.user.id, effect: "allow", role: "viewer" }, + { userId: removed.user.id, effect: "allow", role: "viewer" }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: prototypeWorkspace.id, + createdByUserId: active.user.id, + grants: { + create: [ + { userId: active.user.id, effect: "allow", role: "administrator" }, + { userId: suspended.user.id, effect: "allow", role: "administrator" }, + { userId: removed.user.id, effect: "allow", role: "administrator" }, + ], + }, + }, + }) + + await expect( + service.removeWorkspaceAccess( + "active-product-administrator-token", + workspace.id, + active.user.id, + ), + ).rejects.toMatchObject({ + status: 409, + code: "last_workspace_administrator", + }) + await expect( + service.removePrototypeAccess( + "active-product-administrator-token", + prototype.id, + active.user.id, + ), + ).rejects.toMatchObject({ + status: 409, + code: "last_prototype_administrator", + }) + }) + it("commits one valid rule and an audit for each successful concurrent access change", async () => { const { prisma, service, store } = await database() const administrator = await createActor(store, { @@ -309,6 +604,108 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(audits.every(({ actorUserId }) => actorUserId === administrator.user.id)).toBe(true) }) + it("commits one concurrent archive and returns one archived conflict", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "concurrent-archive", + token: "concurrent-archive-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "editor", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + const lock = await holdPrototypeLock(prisma, prototype.id) + + const first = service.archivePrototype("concurrent-archive-token", prototype.id) + await waitForBlockedRowLock(prisma, "prototypes") + const second = service.archivePrototype("concurrent-archive-token", prototype.id) + lock.release() + await lock.holder + const results = await Promise.allSettled([first, second]) + + expect(results[0]).toMatchObject({ status: "fulfilled" }) + if (results[1]?.status !== "rejected") throw new Error("The second archive should be rejected.") + expect(results[1].reason).toMatchObject({ + status: 409, + code: "prototype_archived", + }) + expect( + await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id, event: "prototype.archive" }, + select: { event: true }, + }), + ).toEqual([{ event: "prototype.archive" }]) + }) + + it("does not commit a version queued behind archival", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "archive-version-race", + token: "archive-version-race-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "editor", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + const lock = await holdPrototypeLock(prisma, prototype.id) + + const archive = service.archivePrototype("archive-version-race-token", prototype.id) + await waitForBlockedRowLock(prisma, "prototypes") + const version = service.createVersion("archive-version-race-token", prototype.id, { + label: "Must not commit", + }) + lock.release() + await lock.holder + + const archived = (await archive) as { id: string; archivedAt: Date | null } + expect(archived.id).toBe(prototype.id) + expect(archived.archivedAt).toBeInstanceOf(Date) + await expect(version).rejects.toMatchObject({ + status: 409, + code: "prototype_archived", + }) + expect(await prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } })).toBe(0) + expect( + await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { createdAt: "asc" }, + select: { event: true }, + }), + ).toEqual([{ event: "prototype.archive" }]) + }) + it("rolls back an access change when its audit cannot be saved", async () => { const { prisma, service, store } = await database() await createActor(store, { @@ -419,6 +816,64 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "prototype-pagination", + token: "prototype-pagination-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + const prototypeIds = [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + "00000000-0000-4000-8000-000000000003", + "00000000-0000-4000-8000-000000000004", + "00000000-0000-4000-8000-000000000005", + "00000000-0000-4000-8000-000000000006", + ] + await prisma.prototype.createMany({ + data: prototypeIds.map((id) => ({ + id, + name: `Prototype ${id.at(-1)}`, + workspaceId: workspace.id, + createdByUserId: actor.user.id, + })), + }) + await prisma.prototypeAccessGrant.createMany({ + data: [prototypeIds[1], prototypeIds[4]].map((prototypeId) => ({ + prototypeId: prototypeId!, + userId: actor.user.id, + effect: "deny", + })), + }) + + const first = (await service.listPrototypes("prototype-pagination-token", workspace.id, { + limit: 2, + })) as { items: { id: string }[]; nextCursor: string | null } + const second = (await service.listPrototypes("prototype-pagination-token", workspace.id, { + limit: 2, + cursor: first.nextCursor, + })) as { items: { id: string }[]; nextCursor: string | null } + + expect(first.items.map(({ id }) => id)).toEqual([prototypeIds[0], prototypeIds[2]]) + expect(first.nextCursor).toBe(prototypeIds[2]) + expect(second.items.map(({ id }) => id)).toEqual([prototypeIds[3], prototypeIds[5]]) + expect(second.nextCursor).toBeNull() + expect(new Set([...first.items, ...second.items].map(({ id }) => id)).size).toBe(4) + }) + it("creates sequential immutable versions and rejects additions after archival", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index de9ce98f..727aebb1 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -116,6 +116,33 @@ async function requireActiveTarget(database: Prisma.TransactionClient, userId: s } } +async function lockWorkspace( + database: Prisma.TransactionClient, + workspaceId: string, +): Promise { + await database.$queryRaw`SELECT "id" FROM "workspaces" WHERE "id" = ${workspaceId}::uuid FOR UPDATE` +} + +async function lockPrototype( + database: Prisma.TransactionClient, + prototypeId: string, +): Promise { + await database.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` +} + +async function lockWorkspacePrototypes( + database: Prisma.TransactionClient, + workspaceId: string, +): Promise { + const prototypes = await database.prototype.findMany({ + where: { workspaceId }, + orderBy: { id: "asc" }, + select: { id: true }, + }) + for (const { id } of prototypes) await lockPrototype(database, id) + return prototypes.map(({ id }) => id) +} + async function workspaceDecision( database: Prisma.TransactionClient, userId: string, @@ -165,6 +192,8 @@ async function hasPrototypeAdministratorAfter( prototypeId: string, targetUserId: string, replacement: GrantInput | null, + scope: "prototype" | "workspace" = "prototype", + excludedUserId?: string, ): Promise { const prototype = await database.prototype.findUnique({ where: { id: prototypeId }, @@ -176,16 +205,55 @@ async function hasPrototypeAdministratorAfter( ...prototype.grants.map(({ userId }) => userId), targetUserId, ]) + const activeMemberships = await database.installationMembership.findMany({ + where: { + userId: { in: [...userIds], ...(excludedUserId ? { not: excludedUserId } : {}) }, + status: "active", + }, + select: { userId: true }, + }) + const activeUserIds = new Set(activeMemberships.map(({ userId }) => userId)) return [...userIds].some((userId) => { - const workspaceGrant = prototype.workspace.grants.find((grant) => grant.userId === userId) + if (!activeUserIds.has(userId)) return false + const workspaceGrant = + scope === "workspace" && userId === targetUserId + ? replacement + : prototype.workspace.grants.find((grant) => grant.userId === userId) const prototypeGrant = - userId === targetUserId + scope === "prototype" && userId === targetUserId ? replacement : prototype.grants.find((grant) => grant.userId === userId) return evaluatePrototypeAccess({ workspaceGrant, prototypeGrant }).role === "administrator" }) } +async function hasWorkspaceAdministratorAfter( + database: Prisma.TransactionClient, + workspaceId: string, + targetUserId: string, + replacement: GrantInput | null, + excludedUserId?: string, +): Promise { + const grants = await database.workspaceAccessGrant.findMany({ where: { workspaceId } }) + const userIds = new Set([...grants.map(({ userId }) => userId), targetUserId]) + const activeMemberships = await database.installationMembership.findMany({ + where: { + userId: { in: [...userIds], ...(excludedUserId ? { not: excludedUserId } : {}) }, + status: "active", + }, + select: { userId: true }, + }) + const activeUserIds = new Set(activeMemberships.map(({ userId }) => userId)) + return [...userIds].some((userId) => { + if (!activeUserIds.has(userId)) return false + const grant = + userId === targetUserId + ? replacement + : grants.find((candidate) => candidate.userId === userId) + return grant?.effect === "allow" && grant.role === "administrator" + }) +} + function pageResult(items: T[], limit: number) { const hasMore = items.length > limit const pageItems = hasMore ? items.slice(0, limit) : items @@ -238,7 +306,7 @@ export function createPrototypeAuthorizationService({ async createWorkspace(sessionToken, input) { const { name } = parseName(input, "invalid_workspace", "The workspace is invalid.") - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) if (actor.membership.role !== "administrator") { @@ -285,24 +353,17 @@ export function createPrototypeAuthorizationService({ async setWorkspaceAccess(sessionToken, workspaceId, targetUserId, input) { const change = parseGrant(input) - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) + requireIdentifier(workspaceId, "workspace_not_found", "The workspace was not found.") + await lockWorkspace(transaction, workspaceId) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "administrator") await requireActiveTarget(transaction, targetUserId) - const existing = await transaction.workspaceAccessGrant.findUnique({ - where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, - }) - const removesAdministrator = - existing?.effect === "allow" && - existing.role === "administrator" && - (change.effect === "deny" || change.role !== "administrator") + const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) if ( - removesAdministrator && - (await transaction.workspaceAccessGrant.count({ - where: { workspaceId, effect: "allow", role: "administrator" }, - })) <= 1 + !(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetUserId, change)) ) { throw new AuthenticationError( 409, @@ -310,6 +371,23 @@ export function createPrototypeAuthorizationService({ "The final workspace administrator cannot be changed.", ) } + for (const prototypeId of prototypeIds) { + if ( + !(await hasPrototypeAdministratorAfter( + transaction, + prototypeId, + targetUserId, + change, + "workspace", + )) + ) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "The access change would leave a prototype without an administrator.", + ) + } + } const grant = await transaction.workspaceAccessGrant.upsert({ where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, create: { workspaceId, userId: targetUserId, ...change }, @@ -326,31 +404,45 @@ export function createPrototypeAuthorizationService({ }, async removeWorkspaceAccess(sessionToken, workspaceId, targetUserId) { - await database.$transaction(async (transaction) => { + await store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) + requireIdentifier(workspaceId, "workspace_not_found", "The workspace was not found.") + await lockWorkspace(transaction, workspaceId) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "administrator") requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") + const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) const existing = await transaction.workspaceAccessGrant.findUnique({ where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, }) if (!existing) { throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") } - if ( - existing.effect === "allow" && - existing.role === "administrator" && - (await transaction.workspaceAccessGrant.count({ - where: { workspaceId, effect: "allow", role: "administrator" }, - })) <= 1 - ) { + if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetUserId, null))) { throw new AuthenticationError( 409, "last_workspace_administrator", "The final workspace administrator cannot be removed.", ) } + for (const prototypeId of prototypeIds) { + if ( + !(await hasPrototypeAdministratorAfter( + transaction, + prototypeId, + targetUserId, + null, + "workspace", + )) + ) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "Removing access would leave a prototype without an administrator.", + ) + } + } await transaction.workspaceAccessGrant.delete({ where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, }) @@ -368,7 +460,10 @@ export function createPrototypeAuthorizationService({ const { decision } = await workspaceDecision(database, actor.userId, workspaceId) requireDecision(decision, "viewer") const items = await database.prototype.findMany({ - where: { workspaceId }, + where: { + workspaceId, + grants: { none: { userId: actor.userId, effect: "deny" } }, + }, orderBy: { id: "asc" }, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, @@ -378,7 +473,7 @@ export function createPrototypeAuthorizationService({ async createPrototype(sessionToken, workspaceId, input) { const { name } = parseName(input, "invalid_prototype", "The prototype is invalid.") - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "editor") @@ -410,8 +505,10 @@ export function createPrototypeAuthorizationService({ }, async archivePrototype(sessionToken, prototypeId) { - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( transaction, actor.userId, @@ -446,9 +543,11 @@ export function createPrototypeAuthorizationService({ async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { const change = parseGrant(input) - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( transaction, actor.userId, @@ -482,9 +581,11 @@ export function createPrototypeAuthorizationService({ }, async removePrototypeAccess(sessionToken, prototypeId, targetUserId) { - await database.$transaction(async (transaction) => { + await store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( transaction, actor.userId, @@ -533,8 +634,10 @@ export function createPrototypeAuthorizationService({ if (!parsed.success) { throw new AuthenticationError(422, "invalid_version", "The version is invalid.") } - return database.$transaction(async (transaction) => { + return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( transaction, actor.userId, @@ -548,7 +651,6 @@ export function createPrototypeAuthorizationService({ "Archived prototypes cannot receive versions.", ) } - await transaction.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` const latest = await transaction.prototypeVersion.findFirst({ where: { prototypeId }, orderBy: { sequence: "desc" }, diff --git a/hub/server/domains/prototype-authorization/ensure-administration-continuity/index.ts b/hub/server/domains/prototype-authorization/ensure-administration-continuity/index.ts new file mode 100644 index 00000000..bf738d7d --- /dev/null +++ b/hub/server/domains/prototype-authorization/ensure-administration-continuity/index.ts @@ -0,0 +1,112 @@ +import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" +import { AuthenticationError } from "#server/utils/authentication-error" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +async function activeUserIds(database: Prisma.TransactionClient, userIds: string[]) { + const memberships = await database.installationMembership.findMany({ + where: { userId: { in: userIds }, status: "active" }, + select: { userId: true }, + }) + return new Set(memberships.map(({ userId }) => userId)) +} + +export async function ensureProductAdministrationContinuity( + database: Prisma.TransactionClient, + userId: string, +): Promise { + const [workspaceGrants, prototypeGrants] = await Promise.all([ + database.workspaceAccessGrant.findMany({ + where: { userId, effect: "allow", role: "administrator" }, + select: { workspaceId: true }, + }), + database.prototypeAccessGrant.findMany({ + where: { userId, effect: "allow", role: "administrator" }, + select: { prototypeId: true, prototype: { select: { workspaceId: true } } }, + }), + ]) + const workspaceIds = [ + ...new Set([ + ...workspaceGrants.map(({ workspaceId }) => workspaceId), + ...prototypeGrants.map(({ prototype }) => prototype.workspaceId), + ]), + ].sort() + for (const workspaceId of workspaceIds) { + await database.$queryRaw`SELECT "id" FROM "workspaces" WHERE "id" = ${workspaceId}::uuid FOR UPDATE` + } + const prototypes = await database.prototype.findMany({ + where: { + OR: [ + { workspaceId: { in: workspaceIds } }, + { id: { in: prototypeGrants.map(({ prototypeId }) => prototypeId) } }, + ], + }, + orderBy: { id: "asc" }, + include: { grants: true, workspace: { include: { grants: true } } }, + }) + for (const { id } of prototypes) { + await database.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${id}::uuid FOR UPDATE` + } + + const currentWorkspaceGrants = await database.workspaceAccessGrant.findMany({ + where: { workspaceId: { in: workspaceIds } }, + }) + const workspaceUsers = [ + ...new Set(currentWorkspaceGrants.map(({ userId: grantUserId }) => grantUserId)), + ] + const activeWorkspaceUsers = await activeUserIds(database, workspaceUsers) + for (const workspaceId of workspaceIds) { + const targetGrant = currentWorkspaceGrants.find( + (grant) => grant.workspaceId === workspaceId && grant.userId === userId, + ) + if (targetGrant?.effect !== "allow" || targetGrant.role !== "administrator") continue + const replacement = currentWorkspaceGrants.some( + (grant) => + grant.workspaceId === workspaceId && + grant.userId !== userId && + activeWorkspaceUsers.has(grant.userId) && + grant.effect === "allow" && + grant.role === "administrator", + ) + if (!replacement) { + throw new AuthenticationError( + 409, + "product_administration_orphaned", + "Transfer workspace administration before changing this membership.", + ) + } + } + + for (const prototype of prototypes) { + const userIds = [ + ...new Set([ + ...prototype.workspace.grants.map(({ userId: grantUserId }) => grantUserId), + ...prototype.grants.map(({ userId: grantUserId }) => grantUserId), + ]), + ] + const activePrototypeUsers = await activeUserIds(database, userIds) + const targetDecision = evaluatePrototypeAccess({ + workspaceGrant: prototype.workspace.grants.find((grant) => grant.userId === userId), + prototypeGrant: prototype.grants.find((grant) => grant.userId === userId), + }) + if (targetDecision.role !== "administrator") continue + const replacement = userIds.some((candidateUserId) => { + if (candidateUserId === userId || !activePrototypeUsers.has(candidateUserId)) return false + return ( + evaluatePrototypeAccess({ + workspaceGrant: prototype.workspace.grants.find( + (grant) => grant.userId === candidateUserId, + ), + prototypeGrant: prototype.grants.find((grant) => grant.userId === candidateUserId), + }).role === "administrator" + ) + }) + if (!replacement) { + throw new AuthenticationError( + 409, + "product_administration_orphaned", + "Transfer prototype administration before changing this membership.", + ) + } + } +} From 0522171925c0e5ea0e9b349cde51b664ba688d08 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 18:34:48 -0700 Subject: [PATCH 03/10] fix(hub): isolate prototype list access Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/prototype-authorization.md | 4 +- hub/docs/testing-and-operations.md | 2 +- .../create-service/index.db.test.ts | 169 +++++++++++++++++- .../create-service/index.ts | 140 +++++++++++++-- .../create-service/types.ts | 4 + 5 files changed, 305 insertions(+), 14 deletions(-) diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index c96f6a09..d0913471 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -37,13 +37,13 @@ The evaluator applies this order: 4. A future optional synchronized baseline may apply. 5. Otherwise Hub denies access. -Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. Prototype listing accepts the same optional baseline policy source and checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. Suspending or removing a membership fails with `product_administration_orphaned` when that user is the final active administrator for a workspace or prototype. An administrator must transfer product administration first. Changing an active installation administrator to a member does not remove an explicit product role, because installation and product authority remain separate. -A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. +A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. Workspace list responses contain only workspace fields and never embed prototype or access-rule metadata. ## API and request boundaries diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 253b4b98..83765b5b 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -57,7 +57,7 @@ The database command uses `--passWithNoTests` so an empty selection exits succes 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, deny-aware pagination, version ownership, archive/version races, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, prototype-specific restrictions, cross-prototype isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, archive/version races, and explainable decisions. The policy evaluator remains a database-free test target so a future synchronized baseline can be added without changing route-specific rules. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 41095980..a2ea45fe 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createIdentityStore } from "#server/domains/identity/create-store" import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import type { PrototypeBaselinePolicy } from "#server/domains/prototype-authorization/create-service/types" import { digestSecret } from "#server/utils/digest-secret" import type { PrismaClient } from "../../../generated/prisma/client.ts" type IdentityStore = ReturnType -async function database() { +async function database(baseline?: PrototypeBaselinePolicy) { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") onTestFinished(() => prisma.$disconnect()) @@ -16,6 +17,7 @@ async function database() { prisma, store: createIdentityStore(prisma), service: createPrototypeAuthorizationService({ + baseline, database: prisma, store: createIdentityStore(prisma), }), @@ -816,6 +818,171 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("returns scalar workspaces without inaccessible prototype or grant metadata", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "workspace-list-leak", + token: "workspace-list-leak-token", + }) + const other = await createActor(store, { + subject: "workspace-list-other", + token: "workspace-list-other-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + prototypes: { + create: { + name: "Denied prototype", + createdByUserId: other.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "deny", + }, + }, + }, + }, + }, + }) + + const page = (await service.listWorkspaces("workspace-list-leak-token", {})) as { + items: Record[] + } + + expect(page.items).toHaveLength(1) + expect(page.items[0]).toMatchObject({ + id: workspace.id, + createdByUserId: actor.user.id, + name: "Workspace", + }) + expect(page.items[0]?.createdAt).toBeInstanceOf(Date) + expect(page.items[0]?.updatedAt).toBeInstanceOf(Date) + expect(page.items[0]).not.toHaveProperty("prototypes") + expect(page.items[0]).not.toHaveProperty("grants") + expect(JSON.stringify(page)).not.toContain("Denied prototype") + }) + + it("returns an empty terminal prototype page for a prototype-only user", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "prototype-only-terminal", + token: "prototype-only-terminal-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + await expect( + service.listPrototypes("prototype-only-terminal-token", workspace.id, { + cursor: prototype.id, + limit: 20, + }), + ).resolves.toEqual({ items: [], nextCursor: null }) + }) + + it("returns an empty terminal prototype page for a baseline-authorized user", async () => { + const baselineRole = vi.fn().mockResolvedValue("viewer") + const baseline: PrototypeBaselinePolicy = { + role: baselineRole, + } + const { prisma, service, store } = await database(baseline) + const actor = await createActor(store, { + subject: "baseline-terminal", + token: "baseline-terminal-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + + await expect( + service.listPrototypes("baseline-terminal-token", workspace.id, { + cursor: prototype.id, + limit: 20, + }), + ).resolves.toEqual({ items: [], nextCursor: null }) + expect(baselineRole.mock.calls).toContainEqual([actor.user.id, prototype.id]) + }) + + it("lets a workspace deny override prototype and baseline access when listing", async () => { + const baseline: PrototypeBaselinePolicy = { + role: vi.fn().mockResolvedValue("viewer"), + } + const { prisma, service, store } = await database(baseline) + const actor = await createActor(store, { + subject: "workspace-denied-prototype-list", + token: "workspace-denied-prototype-list-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "deny", + }, + }, + }, + }) + await prisma.prototype.create({ + data: { + name: "Directly allowed prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + await expect( + service.listPrototypes("workspace-denied-prototype-list-token", workspace.id, {}), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + }) + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 727aebb1..40ba6e98 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -9,6 +9,7 @@ import { import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" import type { GrantInput, + PrototypeBaselinePolicy, PrototypeAuthorizationService, } from "#server/domains/prototype-authorization/create-service/types" import { getCurrentSession } from "#server/domains/authentication/get-current-session" @@ -263,6 +264,106 @@ function pageResult(items: T[], limit: number) { } } +function prototypeDto(prototype: { + id: string + workspaceId: string + createdByUserId: string + name: string + archivedAt: Date | null + createdAt: Date + updatedAt: Date +}) { + return prototype +} + +async function prototypeAccessDecision( + baseline: PrototypeBaselinePolicy | null, + userId: string, + workspaceGrant: GrantInput | null, + prototype: { + id: string + grants: GrantInput[] + }, +) { + return evaluatePrototypeAccess({ + workspaceGrant, + prototypeGrant: prototype.grants[0] ?? null, + baselineRole: baseline ? await baseline.role(userId, prototype.id) : null, + }) +} + +async function readAccessiblePrototypePage({ + baseline, + database, + userId, + workspaceId, + workspaceGrant, + cursor, + limit, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + workspaceId: string + workspaceGrant: GrantInput | null + cursor?: string + limit: number +}) { + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.prototype.findMany({ + where: { workspaceId }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + const decision = await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate) + if (decision.allowed) items.push(prototypeDto(candidate)) + if (items.length > limit) break + } + if (candidates.length < 100) break + } + return pageResult(items, limit) +} + +async function hasAnyPrototypeAccess({ + baseline, + database, + userId, + workspaceId, + workspaceGrant, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + workspaceId: string + workspaceGrant: GrantInput | null +}) { + if (evaluatePrototypeAccess({ workspaceGrant }).allowed) return true + let cursor: string | undefined + while (true) { + const candidates = await database.prototype.findMany({ + where: { workspaceId }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: 100, + }) + for (const candidate of candidates) { + if ((await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate)).allowed) { + return true + } + } + if (candidates.length < 100) return false + cursor = candidates.at(-1)?.id + } +} + async function audit( database: Prisma.TransactionClient, actor: Awaited>, @@ -280,9 +381,11 @@ async function audit( } export function createPrototypeAuthorizationService({ + baseline = null, database, store, }: { + baseline?: PrototypeBaselinePolicy | null database: PrismaClient store: IdentityStore }): PrototypeAuthorizationService { @@ -300,6 +403,13 @@ export function createPrototypeAuthorizationService({ orderBy: { id: "asc" }, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, + select: { + id: true, + createdByUserId: true, + name: true, + createdAt: true, + updatedAt: true, + }, }) return pageResult(items, limit) }, @@ -457,18 +567,28 @@ export function createPrototypeAuthorizationService({ async listPrototypes(sessionToken, workspaceId, query) { const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) - const { decision } = await workspaceDecision(database, actor.userId, workspaceId) - requireDecision(decision, "viewer") - const items = await database.prototype.findMany({ - where: { + const { workspace } = await workspaceDecision(database, actor.userId, workspaceId) + const workspaceGrant = workspace.grants[0] ?? null + if ( + !(await hasAnyPrototypeAccess({ + baseline, + database, + userId: actor.userId, workspaceId, - grants: { none: { userId: actor.userId, effect: "deny" } }, - }, - orderBy: { id: "asc" }, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - take: limit + 1, + workspaceGrant, + })) + ) { + throw new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied.") + } + return readAccessiblePrototypePage({ + baseline, + database, + userId: actor.userId, + workspaceId, + workspaceGrant, + cursor, + limit, }) - return pageResult(items, limit) }, async createPrototype(sessionToken, workspaceId, input) { diff --git a/hub/server/domains/prototype-authorization/create-service/types.ts b/hub/server/domains/prototype-authorization/create-service/types.ts index 20f044af..fc197973 100644 --- a/hub/server/domains/prototype-authorization/create-service/types.ts +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -45,3 +45,7 @@ export interface GrantInput { effect: "allow" | "deny" role: PrototypeRole | null } + +export interface PrototypeBaselinePolicy { + role(userId: string, prototypeId: string): Promise +} From 98cbe7164998613b5d3ed0a6f093e4861a730de7 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 19:29:49 -0700 Subject: [PATCH 04/10] fix(hub): defer baseline authorization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/prototype-authorization.md | 2 +- .../create-service/index.db.test.ts | 45 ++++++++++++++++++- .../create-service/index.ts | 7 ++- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index d0913471..31c7aaa8 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -37,7 +37,7 @@ The evaluator applies this order: 4. A future optional synchronized baseline may apply. 5. Otherwise Hub denies access. -Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. Prototype listing accepts the same optional baseline policy source and checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. Routes use this evaluator instead of reimplementing precedence. Hub consults an optional baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing accepts that baseline policy source and checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. This release does not supply a synchronized baseline. Future GitHub synchronization must remain below explicit Hub deny and explicit Hub role rules. Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index a2ea45fe..d9161391 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -940,8 +940,11 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) it("lets a workspace deny override prototype and baseline access when listing", async () => { + const baselineRole = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) const baseline: PrototypeBaselinePolicy = { - role: vi.fn().mockResolvedValue("viewer"), + role: baselineRole, } const { prisma, service, store } = await database(baseline) const actor = await createActor(store, { @@ -981,6 +984,46 @@ describe("createPrototypeAuthorizationService() database behavior", () => { status: 403, code: "prototype_access_denied", }) + expect(baselineRole.mock.calls).toHaveLength(0) + }) + + it("uses an explicit prototype role without consulting a failing baseline", async () => { + const baselineRole = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) + const { prisma, service, store } = await database({ role: baselineRole }) + const actor = await createActor(store, { + subject: "explicit-role-baseline-failure", + token: "explicit-role-baseline-failure-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + await expect( + service.listPrototypes("explicit-role-baseline-failure-token", workspace.id, {}), + ).resolves.toMatchObject({ + items: [{ id: prototype.id }], + nextCursor: null, + }) + expect(baselineRole.mock.calls).toHaveLength(0) }) it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 40ba6e98..d1fa38c1 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -285,10 +285,13 @@ async function prototypeAccessDecision( grants: GrantInput[] }, ) { - return evaluatePrototypeAccess({ + const hubDecision = evaluatePrototypeAccess({ workspaceGrant, prototypeGrant: prototype.grants[0] ?? null, - baselineRole: baseline ? await baseline.role(userId, prototype.id) : null, + }) + if (hubDecision.source !== "none" || !baseline) return hubDecision + return evaluatePrototypeAccess({ + baselineRole: await baseline.role(userId, prototype.id), }) } From 23590621fe514e9ddb20b91bd608903f262d9f5d Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 13:57:38 -0700 Subject: [PATCH 05/10] fix(hub): address authorization review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 530 +++++++++++++++++- .../create-service/index.ts | 112 ++-- 2 files changed, 603 insertions(+), 39 deletions(-) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index d9161391..372bc485 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -201,6 +201,174 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("rejects workspace creation with stale verification without writing product state", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "stale-workspace-creator", + token: "stale-workspace-creator-token", + role: "administrator", + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + + await expect( + service.createWorkspace("stale-workspace-creator-token", { name: "Rejected workspace" }), + ).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + expect(await prisma.workspace.count()).toBe(0) + expect(await prisma.workspaceAccessGrant.count()).toBe(0) + expect(await prisma.securityAudit.count()).toBe(0) + }) + + it("rejects workspace creation by a member without writing product state", async () => { + const { prisma, service, store } = await database() + await createActor(store, { + subject: "member-workspace-creator", + token: "member-workspace-creator-token", + }) + + await expect( + service.createWorkspace("member-workspace-creator-token", { name: "Rejected workspace" }), + ).rejects.toMatchObject({ + status: 403, + code: "installation_administrator_required", + }) + expect(await prisma.workspace.count()).toBe(0) + expect(await prisma.workspaceAccessGrant.count()).toBe(0) + expect(await prisma.securityAudit.count()).toBe(0) + }) + + it("rejects prototype creation by a workspace viewer without writing product state", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "viewer-prototype-creator", + token: "viewer-prototype-creator-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + await expect( + service.createPrototype("viewer-prototype-creator-token", workspace.id, { + name: "Rejected prototype", + }), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect(await prisma.prototype.count()).toBe(0) + expect(await prisma.securityAudit.count()).toBe(0) + }) + + it.each([ + { scope: "workspace", action: "change" }, + { scope: "workspace", action: "remove" }, + { scope: "prototype", action: "change" }, + { scope: "prototype", action: "remove" }, + ] as const)( + "rejects stale verification for a $scope access $action without changing access or audits", + async ({ scope, action }) => { + const { prisma, service, store } = await database() + const token = `stale-${scope}-${action}-actor-token` + const actor = await createActor(store, { + subject: `stale-${scope}-${action}-actor`, + token, + role: "administrator", + }) + const target = await createActor(store, { + subject: `stale-${scope}-${action}-target`, + token: `stale-${scope}-${action}-target-token`, + }) + const workspace = (await service.createWorkspace(token, { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype(token, workspace.id, { + name: "Prototype", + })) as { id: string } + await prisma.workspaceAccessGrant.create({ + data: { + workspaceId: workspace.id, + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: prototype.id, + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + + const operation = + scope === "workspace" + ? action === "change" + ? service.setWorkspaceAccess(token, workspace.id, target.user.id, { + effect: "allow", + role: "commenter", + }) + : service.removeWorkspaceAccess(token, workspace.id, target.user.id) + : action === "change" + ? service.setPrototypeAccess(token, prototype.id, target.user.id, { + effect: "allow", + role: "commenter", + }) + : service.removePrototypeAccess(token, prototype.id, target.user.id) + + await expect(operation).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: target.user.id, + }, + }, + select: { effect: true, role: true }, + }), + ).toEqual({ effect: "allow", role: "viewer" }) + expect( + await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: target.user.id, + }, + }, + select: { effect: true, role: true }, + }), + ).toEqual({ effect: "allow", role: "viewer" }) + expect( + await prisma.securityAudit.count({ + where: { targetUserId: target.user.id }, + }), + ).toBe(0) + }, + ) + it("isolates grants and immutable versions by prototype", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { subject: "isolated", token: "isolated-token" }) @@ -544,6 +712,105 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it.each([ + { scope: "workspace", action: "change" }, + { scope: "workspace", action: "remove" }, + { scope: "prototype", action: "change" }, + { scope: "prototype", action: "remove" }, + ] as const)( + "does not let an uppercase UUID bypass the final $scope administrator check on $action", + async ({ scope, action }) => { + const { prisma, service, store } = await database() + const token = `uppercase-${scope}-${action}-token` + const actor = await createActor(store, { + subject: `uppercase-${scope}-${action}`, + token, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: scope === "workspace" ? "administrator" : "viewer", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: + scope === "prototype" + ? { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + } + : undefined, + }, + }) + const uppercaseUserId = actor.user.id.toUpperCase() + + const operation = + scope === "workspace" + ? action === "change" + ? service.setWorkspaceAccess(token, workspace.id, uppercaseUserId, { + effect: "allow", + role: "viewer", + }) + : service.removeWorkspaceAccess(token, workspace.id, uppercaseUserId) + : action === "change" + ? service.setPrototypeAccess(token, prototype.id, uppercaseUserId, { + effect: "allow", + role: "viewer", + }) + : service.removePrototypeAccess(token, prototype.id, uppercaseUserId) + + await expect(operation).rejects.toMatchObject({ + status: 409, + code: + scope === "workspace" ? "last_workspace_administrator" : "last_prototype_administrator", + }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace.id, + userId: actor.user.id, + }, + }, + select: { effect: true, role: true }, + }), + ).toEqual({ + effect: "allow", + role: scope === "workspace" ? "administrator" : "viewer", + }) + expect( + await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: actor.user.id, + }, + }, + select: { effect: true, role: true }, + }), + ).toEqual(scope === "prototype" ? { effect: "allow", role: "administrator" } : null) + expect( + await prisma.securityAudit.count({ + where: { event: { startsWith: `${scope}.access.` } }, + }), + ).toBe(0) + }, + ) + it("commits one valid rule and an audit for each successful concurrent access change", async () => { const { prisma, service, store } = await database() const administrator = await createActor(store, { @@ -939,6 +1206,171 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(baselineRole.mock.calls).toContainEqual([actor.user.id, prototype.id]) }) + it("uses baseline authorization for single-prototype reads and role-gated mutations", async () => { + const baselineRole = vi.fn().mockResolvedValue("administrator") + const { prisma, service, store } = await database({ role: baselineRole }) + const actor = await createActor(store, { + subject: "baseline-single-prototype", + token: "baseline-single-prototype-token", + }) + const owner = await createActor(store, { + subject: "baseline-single-prototype-owner", + token: "baseline-single-prototype-owner-token", + }) + const target = await createActor(store, { + subject: "baseline-single-prototype-target", + token: "baseline-single-prototype-target-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: owner.user.id, + grants: { + create: { + userId: owner.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: owner.user.id, + }, + }) + const firstVersion = await prisma.prototypeVersion.create({ + data: { + prototypeId: prototype.id, + createdByUserId: owner.user.id, + sequence: 1, + label: "First", + }, + }) + + await expect( + service.getPrototype("baseline-single-prototype-token", prototype.id), + ).resolves.toMatchObject({ id: prototype.id }) + await expect( + service.explainPrototypeAccess("baseline-single-prototype-token", prototype.id), + ).resolves.toMatchObject({ + allowed: true, + role: "administrator", + source: "baseline", + }) + await expect( + service.listVersions("baseline-single-prototype-token", prototype.id, {}), + ).resolves.toMatchObject({ items: [{ id: firstVersion.id }] }) + await expect( + service.getVersion("baseline-single-prototype-token", prototype.id, firstVersion.id), + ).resolves.toMatchObject({ id: firstVersion.id }) + await expect( + service.createVersion("baseline-single-prototype-token", prototype.id, { + label: "Second", + }), + ).resolves.toMatchObject({ sequence: 2 }) + await expect( + service.setPrototypeAccess("baseline-single-prototype-token", prototype.id, target.user.id, { + effect: "allow", + role: "viewer", + }), + ).resolves.toMatchObject({ role: "viewer" }) + await expect( + service.removePrototypeAccess( + "baseline-single-prototype-token", + prototype.id, + target.user.id, + ), + ).resolves.toBeUndefined() + const archived = (await service.archivePrototype( + "baseline-single-prototype-token", + prototype.id, + )) as { archivedAt: Date | null } + expect(archived.archivedAt).toBeInstanceOf(Date) + + expect(baselineRole.mock.calls.length).toBeGreaterThanOrEqual(8) + expect( + baselineRole.mock.calls.every( + ([userId, prototypeId]) => userId === actor.user.id && prototypeId === prototype.id, + ), + ).toBe(true) + expect( + await prisma.prototypeAccessGrant.findUnique({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: target.user.id, + }, + }, + }), + ).toBeNull() + }) + + it("short-circuits the baseline for explicit prototype roles and denies", async () => { + const baselineRole = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) + const { prisma, service, store } = await database({ role: baselineRole }) + const actor = await createActor(store, { + subject: "single-prototype-short-circuit", + token: "single-prototype-short-circuit-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const [allowed, denied] = await Promise.all([ + prisma.prototype.create({ + data: { + name: "Allowed", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }), + prisma.prototype.create({ + data: { + name: "Denied", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "deny", + }, + }, + }, + }), + ]) + + await expect( + service.getPrototype("single-prototype-short-circuit-token", allowed.id), + ).resolves.toMatchObject({ id: allowed.id }) + await expect( + service.getPrototype("single-prototype-short-circuit-token", denied.id), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + await expect( + service.explainPrototypeAccess("single-prototype-short-circuit-token", denied.id), + ).resolves.toMatchObject({ + allowed: false, + source: "hub-prototype", + }) + expect(baselineRole).not.toHaveBeenCalled() + }) + it("lets a workspace deny override prototype and baseline access when listing", async () => { const baselineRole = vi .fn() @@ -977,6 +1409,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }, }, }) + const prototypeScan = vi.spyOn(prisma.prototype, "findMany") await expect( service.listPrototypes("workspace-denied-prototype-list-token", workspace.id, {}), @@ -984,6 +1417,7 @@ describe("createPrototypeAuthorizationService() database behavior", () => { status: 403, code: "prototype_access_denied", }) + expect(prototypeScan).not.toHaveBeenCalled() expect(baselineRole.mock.calls).toHaveLength(0) }) @@ -1017,12 +1451,26 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }, }) - await expect( - service.listPrototypes("explicit-role-baseline-failure-token", workspace.id, {}), - ).resolves.toMatchObject({ + const page = (await service.listPrototypes( + "explicit-role-baseline-failure-token", + workspace.id, + {}, + )) as { items: Record[]; nextCursor: string | null } + expect(page).toMatchObject({ items: [{ id: prototype.id }], nextCursor: null, }) + expect(Object.keys(page.items[0] ?? {}).sort()).toEqual( + [ + "archivedAt", + "createdAt", + "createdByUserId", + "id", + "name", + "updatedAt", + "workspaceId", + ].sort(), + ) expect(baselineRole.mock.calls).toHaveLength(0) }) @@ -1084,6 +1532,82 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(new Set([...first.items, ...second.items].map(({ id }) => id)).size).toBe(4) }) + it("lists immutable versions by sequence and paginates in that order", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "version-sequence-pagination", + token: "version-sequence-pagination-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + const versions = [ + { + id: "ffffffff-ffff-4fff-8fff-ffffffffffff", + sequence: 1, + label: "First immutable version", + }, + { + id: "00000000-0000-4000-8000-000000000001", + sequence: 2, + label: "Second immutable version", + }, + { + id: "11111111-1111-4111-8111-111111111111", + sequence: 3, + label: "Third immutable version", + }, + ] + await prisma.prototypeVersion.createMany({ + data: versions.map((version) => ({ + ...version, + prototypeId: prototype.id, + createdByUserId: actor.user.id, + })), + }) + + const first = (await service.listVersions("version-sequence-pagination-token", prototype.id, { + limit: 2, + })) as { + items: { id: string; sequence: number; label: string | null }[] + nextCursor: string | null + } + const second = (await service.listVersions("version-sequence-pagination-token", prototype.id, { + limit: 2, + cursor: first.nextCursor, + })) as { + items: { id: string; sequence: number; label: string | null }[] + nextCursor: string | null + } + + expect(first.items.map(({ sequence, label }) => ({ sequence, label }))).toEqual([ + { sequence: 1, label: "First immutable version" }, + { sequence: 2, label: "Second immutable version" }, + ]) + expect(first.nextCursor).toBe(versions[1]?.id) + expect(second.items.map(({ sequence, label }) => ({ sequence, label }))).toEqual([ + { sequence: 3, label: "Third immutable version" }, + ]) + expect(second.nextCursor).toBeNull() + }) + it("creates sequential immutable versions and rejects additions after archival", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index d1fa38c1..01e110c1 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -79,6 +79,11 @@ function requireIdentifier(value: string, code: string, message: string): void { } } +function normalizeIdentifier(value: string, code: string, message: string): string { + requireIdentifier(value, code, message) + return value.toLowerCase() +} + function requireDecision(decision: AccessDecision, required: PrototypeRole): void { if (!decision.allowed || !hasRole(decision.role, required)) { throw new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied.") @@ -164,6 +169,7 @@ async function workspaceDecision( } async function prototypeDecision( + baseline: PrototypeBaselinePolicy | null, database: Prisma.TransactionClient, userId: string, prototypeId: string, @@ -181,10 +187,12 @@ async function prototypeDecision( } return { prototype, - decision: evaluatePrototypeAccess({ - workspaceGrant: prototype.workspace.grants[0] ?? null, - prototypeGrant: prototype.grants[0] ?? null, - }), + decision: await prototypeAccessDecision( + baseline, + userId, + prototype.workspace.grants[0] ?? null, + prototype, + ), } } @@ -273,7 +281,15 @@ function prototypeDto(prototype: { createdAt: Date updatedAt: Date }) { - return prototype + return { + id: prototype.id, + workspaceId: prototype.workspaceId, + createdByUserId: prototype.createdByUserId, + name: prototype.name, + archivedAt: prototype.archivedAt, + createdAt: prototype.createdAt, + updatedAt: prototype.updatedAt, + } } async function prototypeAccessDecision( @@ -347,7 +363,8 @@ async function hasAnyPrototypeAccess({ workspaceId: string workspaceGrant: GrantInput | null }) { - if (evaluatePrototypeAccess({ workspaceGrant }).allowed) return true + const workspaceDecision = evaluatePrototypeAccess({ workspaceGrant }) + if (workspaceDecision.source !== "none") return workspaceDecision.allowed let cursor: string | undefined while (true) { const candidates = await database.prototype.findMany({ @@ -466,6 +483,11 @@ export function createPrototypeAuthorizationService({ async setWorkspaceAccess(sessionToken, workspaceId, targetUserId, input) { const change = parseGrant(input) + const targetId = normalizeIdentifier( + targetUserId, + "target_user_inactive", + "The target user is not active.", + ) return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) @@ -473,11 +495,9 @@ export function createPrototypeAuthorizationService({ await lockWorkspace(transaction, workspaceId) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "administrator") - await requireActiveTarget(transaction, targetUserId) + await requireActiveTarget(transaction, targetId) const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) - if ( - !(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetUserId, change)) - ) { + if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetId, change))) { throw new AuthenticationError( 409, "last_workspace_administrator", @@ -489,7 +509,7 @@ export function createPrototypeAuthorizationService({ !(await hasPrototypeAdministratorAfter( transaction, prototypeId, - targetUserId, + targetId, change, "workspace", )) @@ -502,14 +522,14 @@ export function createPrototypeAuthorizationService({ } } const grant = await transaction.workspaceAccessGrant.upsert({ - where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, - create: { workspaceId, userId: targetUserId, ...change }, + where: { workspaceId_userId: { workspaceId, userId: targetId } }, + create: { workspaceId, userId: targetId, ...change }, update: change, }) await audit(transaction, actor, { event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", workspaceId, - targetUserId, + targetUserId: targetId, context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, }) return grant @@ -517,6 +537,11 @@ export function createPrototypeAuthorizationService({ }, async removeWorkspaceAccess(sessionToken, workspaceId, targetUserId) { + const targetId = normalizeIdentifier( + targetUserId, + "access_not_found", + "The access rule was not found.", + ) await store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) @@ -524,15 +549,14 @@ export function createPrototypeAuthorizationService({ await lockWorkspace(transaction, workspaceId) const { decision } = await workspaceDecision(transaction, actor.userId, workspaceId) requireDecision(decision, "administrator") - requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) const existing = await transaction.workspaceAccessGrant.findUnique({ - where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + where: { workspaceId_userId: { workspaceId, userId: targetId } }, }) if (!existing) { throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") } - if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetUserId, null))) { + if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetId, null))) { throw new AuthenticationError( 409, "last_workspace_administrator", @@ -544,7 +568,7 @@ export function createPrototypeAuthorizationService({ !(await hasPrototypeAdministratorAfter( transaction, prototypeId, - targetUserId, + targetId, null, "workspace", )) @@ -557,12 +581,12 @@ export function createPrototypeAuthorizationService({ } } await transaction.workspaceAccessGrant.delete({ - where: { workspaceId_userId: { workspaceId, userId: targetUserId } }, + where: { workspaceId_userId: { workspaceId, userId: targetId } }, }) await audit(transaction, actor, { event: "workspace.access.remove", workspaceId, - targetUserId, + targetUserId: targetId, }) }) }, @@ -614,7 +638,12 @@ export function createPrototypeAuthorizationService({ async getPrototype(sessionToken, prototypeId) { const actor = await currentSession(store, sessionToken) - const { prototype, decision } = await prototypeDecision(database, actor.userId, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + database, + actor.userId, + prototypeId, + ) requireDecision(decision, "viewer") return { id: prototype.id, @@ -633,6 +662,7 @@ export function createPrototypeAuthorizationService({ requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, @@ -660,27 +690,31 @@ export function createPrototypeAuthorizationService({ async explainPrototypeAccess(sessionToken, prototypeId) { const actor = await currentSession(store, sessionToken) - const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) return decision }, async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { const change = parseGrant(input) + const targetId = normalizeIdentifier( + targetUserId, + "target_user_inactive", + "The target user is not active.", + ) return store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, ) requireDecision(decision, "administrator") - await requireActiveTarget(transaction, targetUserId) - if ( - !(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetUserId, change)) - ) { + await requireActiveTarget(transaction, targetId) + if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, change))) { throw new AuthenticationError( 409, "last_prototype_administrator", @@ -688,15 +722,15 @@ export function createPrototypeAuthorizationService({ ) } const grant = await transaction.prototypeAccessGrant.upsert({ - where: { prototypeId_userId: { prototypeId, userId: targetUserId } }, - create: { prototypeId, userId: targetUserId, ...change }, + where: { prototypeId_userId: { prototypeId, userId: targetId } }, + create: { prototypeId, userId: targetId, ...change }, update: change, }) await audit(transaction, actor, { event: change.effect === "deny" ? "prototype.access.deny" : "prototype.access.grant", workspaceId: prototype.workspaceId, prototypeId, - targetUserId, + targetUserId: targetId, context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, }) return grant @@ -704,19 +738,24 @@ export function createPrototypeAuthorizationService({ }, async removePrototypeAccess(sessionToken, prototypeId, targetUserId) { + const targetId = normalizeIdentifier( + targetUserId, + "access_not_found", + "The access rule was not found.", + ) await store.lock(async (_records, transaction) => { const actor = await currentSession(store, sessionToken, transaction) requireFreshAuthorization(actor) requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, ) requireDecision(decision, "administrator") - requireIdentifier(targetUserId, "access_not_found", "The access rule was not found.") - if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetUserId, null))) { + if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, null))) { throw new AuthenticationError( 409, "last_prototype_administrator", @@ -724,7 +763,7 @@ export function createPrototypeAuthorizationService({ ) } const removed = await transaction.prototypeAccessGrant.deleteMany({ - where: { prototypeId, userId: targetUserId }, + where: { prototypeId, userId: targetId }, }) if (removed.count === 0) { throw new AuthenticationError(404, "access_not_found", "The access rule was not found.") @@ -733,7 +772,7 @@ export function createPrototypeAuthorizationService({ event: "prototype.access.remove", workspaceId: prototype.workspaceId, prototypeId, - targetUserId, + targetUserId: targetId, }) }) }, @@ -741,11 +780,11 @@ export function createPrototypeAuthorizationService({ async listVersions(sessionToken, prototypeId, query) { const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) - const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) requireDecision(decision, "viewer") const items = await database.prototypeVersion.findMany({ where: { prototypeId }, - orderBy: { id: "asc" }, + orderBy: { sequence: "asc" }, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, }) @@ -762,6 +801,7 @@ export function createPrototypeAuthorizationService({ requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") await lockPrototype(transaction, prototypeId) const { prototype, decision } = await prototypeDecision( + baseline, transaction, actor.userId, prototypeId, @@ -798,7 +838,7 @@ export function createPrototypeAuthorizationService({ async getVersion(sessionToken, prototypeId, versionId) { const actor = await currentSession(store, sessionToken) - const { decision } = await prototypeDecision(database, actor.userId, prototypeId) + const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) requireDecision(decision, "viewer") requireIdentifier(versionId, "version_not_found", "The version was not found.") const version = await database.prototypeVersion.findFirst({ From 8ce52db06927ef9f809552bc0cf72b916de1178a Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Wed, 28 Oct 2026 15:47:31 -0700 Subject: [PATCH 06/10] fix(hub): bound authorization pagination Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 120 ++++++++++++++++++ .../create-service/index.ts | 34 ++++- 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 372bc485..e7cbe622 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -369,6 +369,77 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }, ) + it.each([ + { scope: "workspace", targetStatus: "unknown" }, + { scope: "workspace", targetStatus: "suspended" }, + { scope: "workspace", targetStatus: "removed" }, + { scope: "prototype", targetStatus: "unknown" }, + { scope: "prototype", targetStatus: "suspended" }, + { scope: "prototype", targetStatus: "removed" }, + ] as const)( + "rejects a $targetStatus target for a $scope grant without writing access or audits", + async ({ scope, targetStatus }) => { + const { prisma, service, store } = await database() + const token = `inactive-${scope}-${targetStatus}-actor-token` + await createActor(store, { + subject: `inactive-${scope}-${targetStatus}-actor`, + token, + role: "administrator", + }) + const workspace = (await service.createWorkspace(token, { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype(token, workspace.id, { + name: "Prototype", + })) as { id: string } + const targetUserId = + targetStatus === "unknown" + ? "00000000-0000-4000-8000-000000000099" + : ( + await createActor(store, { + subject: `inactive-${scope}-${targetStatus}-target`, + token: `inactive-${scope}-${targetStatus}-target-token`, + }) + ).user.id + if (targetStatus !== "unknown") { + await prisma.installationMembership.update({ + where: { userId: targetUserId }, + data: + targetStatus === "suspended" + ? { status: "suspended", suspendedAt: new Date(), removedAt: null } + : { status: "removed", suspendedAt: null, removedAt: new Date() }, + }) + } + + const operation = + scope === "workspace" + ? service.setWorkspaceAccess(token, workspace.id, targetUserId, { + effect: "allow", + role: "viewer", + }) + : service.setPrototypeAccess(token, prototype.id, targetUserId, { + effect: "allow", + role: "viewer", + }) + + await expect(operation).rejects.toMatchObject({ + status: 422, + code: "target_user_inactive", + }) + expect( + await prisma.workspaceAccessGrant.count({ + where: { workspaceId: workspace.id, userId: targetUserId }, + }), + ).toBe(0) + expect( + await prisma.prototypeAccessGrant.count({ + where: { prototypeId: prototype.id, userId: targetUserId }, + }), + ).toBe(0) + expect(await prisma.securityAudit.count({ where: { targetUserId } })).toBe(0) + }, + ) + it("isolates grants and immutable versions by prototype", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { subject: "isolated", token: "isolated-token" }) @@ -1085,6 +1156,55 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it("continues workspace pagination after access to the prior cursor is revoked", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "workspace-revoked-cursor", + token: "workspace-revoked-cursor-token", + }) + const workspaceIds = [ + "00000000-0000-4000-8000-000000000011", + "00000000-0000-4000-8000-000000000012", + "00000000-0000-4000-8000-000000000013", + ] + for (const id of workspaceIds) { + await prisma.workspace.create({ + data: { + id, + name: `Workspace ${id.at(-1)}`, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + } + + const first = (await service.listWorkspaces("workspace-revoked-cursor-token", { + limit: 1, + })) as { items: { id: string }[]; nextCursor: string | null } + await prisma.workspaceAccessGrant.delete({ + where: { + workspaceId_userId: { + workspaceId: workspaceIds[0]!, + userId: actor.user.id, + }, + }, + }) + const second = (await service.listWorkspaces("workspace-revoked-cursor-token", { + limit: 1, + cursor: first.nextCursor, + })) as { items: { id: string }[]; nextCursor: string | null } + + expect(first.items.map(({ id }) => id)).toEqual([workspaceIds[0]]) + expect(second.items.map(({ id }) => id)).toEqual([workspaceIds[1]]) + expect(second.nextCursor).toBe(workspaceIds[1]) + }) + it("returns scalar workspaces without inaccessible prototype or grant metadata", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index 01e110c1..c24e7ebb 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -328,14 +328,28 @@ async function readAccessiblePrototypePage({ cursor?: string limit: number }) { + if (!baseline) { + const accessWhere = workspaceGrant + ? { grants: { none: { userId, effect: "deny" as const } } } + : { grants: { some: { userId, effect: "allow" as const } } } + const items = await database.prototype.findMany({ + where: { + workspaceId, + ...(cursor ? { id: { gt: cursor } } : {}), + ...accessWhere, + }, + orderBy: { id: "asc" }, + take: limit + 1, + }) + return pageResult(items.map(prototypeDto), limit) + } const items = [] let scanCursor = cursor while (items.length <= limit) { const candidates = await database.prototype.findMany({ - where: { workspaceId }, + where: { workspaceId, ...(scanCursor ? { id: { gt: scanCursor } } : {}) }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, - ...(scanCursor ? { cursor: { id: scanCursor }, skip: 1 } : {}), take: 100, }) if (candidates.length === 0) break @@ -365,13 +379,23 @@ async function hasAnyPrototypeAccess({ }) { const workspaceDecision = evaluatePrototypeAccess({ workspaceGrant }) if (workspaceDecision.source !== "none") return workspaceDecision.allowed + if (!baseline) { + return Boolean( + await database.prototype.findFirst({ + where: { + workspaceId, + grants: { some: { userId, effect: "allow" } }, + }, + select: { id: true }, + }), + ) + } let cursor: string | undefined while (true) { const candidates = await database.prototype.findMany({ - where: { workspaceId }, + where: { workspaceId, ...(cursor ? { id: { gt: cursor } } : {}) }, include: { grants: { where: { userId } } }, orderBy: { id: "asc" }, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: 100, }) for (const candidate of candidates) { @@ -415,13 +439,13 @@ export function createPrototypeAuthorizationService({ const actor = await currentSession(store, sessionToken) const items = await database.workspace.findMany({ where: { + ...(cursor ? { id: { gt: cursor } } : {}), grants: { some: { userId: actor.userId, effect: "allow" }, none: { userId: actor.userId, effect: "deny" }, }, }, orderBy: { id: "asc" }, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, select: { id: true, From 5ecab7f3d6540264cce81fcbfcd03b3be5c186eb Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 04:39:37 -0700 Subject: [PATCH 07/10] test(hub): cover authorization boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.db.test.ts | 226 ++++++++++++++++++ hub/server/routes/product/index.test.ts | 124 ++++++++++ 2 files changed, 350 insertions(+) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index e7cbe622..1d1ec9b7 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -274,6 +274,153 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(await prisma.securityAudit.count()).toBe(0) }) + it.each([ + { scope: "workspace", action: "change" }, + { scope: "workspace", action: "remove" }, + { scope: "prototype", action: "change" }, + { scope: "prototype", action: "remove" }, + ] as const)( + "rejects an editor attempting to $action $scope access without changing grants or audits", + async ({ scope, action }) => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: `editor-${scope}-${action}`, + token: `editor-${scope}-${action}-token`, + }) + const target = await createActor(store, { + subject: `editor-${scope}-${action}-target`, + token: `editor-${scope}-${action}-target-token`, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: [ + { userId: actor.user.id, effect: "allow", role: "editor" }, + { userId: target.user.id, effect: "allow", role: "viewer" }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + const operation = + scope === "workspace" + ? action === "change" + ? service.setWorkspaceAccess( + `editor-${scope}-${action}-token`, + workspace.id, + target.user.id, + { effect: "allow", role: "commenter" }, + ) + : service.removeWorkspaceAccess( + `editor-${scope}-${action}-token`, + workspace.id, + target.user.id, + ) + : action === "change" + ? service.setPrototypeAccess( + `editor-${scope}-${action}-token`, + prototype.id, + target.user.id, + { effect: "allow", role: "commenter" }, + ) + : service.removePrototypeAccess( + `editor-${scope}-${action}-token`, + prototype.id, + target.user.id, + ) + + await expect(operation).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect( + await prisma.workspaceAccessGrant.findMany({ + where: { workspaceId: workspace.id }, + orderBy: { userId: "asc" }, + select: { userId: true, effect: true, role: true }, + }), + ).toEqual( + [ + { userId: actor.user.id, effect: "allow", role: "editor" }, + { userId: target.user.id, effect: "allow", role: "viewer" }, + ].sort((left, right) => left.userId.localeCompare(right.userId)), + ) + expect( + await prisma.prototypeAccessGrant.findMany({ + where: { prototypeId: prototype.id }, + select: { userId: true, effect: true, role: true }, + }), + ).toEqual([{ userId: target.user.id, effect: "allow", role: "viewer" }]) + expect(await prisma.securityAudit.count()).toBe(0) + }, + ) + + it.each([{ action: "archive" }, { action: "create a version" }] as const)( + "rejects a commenter attempting to $action without changing product state or audits", + async ({ action }) => { + const { prisma, service, store } = await database() + const token = `commenter-${action.replaceAll(" ", "-")}-token` + const actor = await createActor(store, { + subject: `commenter-${action.replaceAll(" ", "-")}`, + token, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "commenter", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + + const operation = + action === "archive" + ? service.archivePrototype(token, prototype.id) + : service.createVersion(token, prototype.id, { label: "Rejected version" }) + + await expect(operation).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect( + await prisma.prototype.findUnique({ + where: { id: prototype.id }, + select: { archivedAt: true }, + }), + ).toEqual({ archivedAt: null }) + expect(await prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } })).toBe(0) + expect(await prisma.securityAudit.count()).toBe(0) + }, + ) + it.each([ { scope: "workspace", action: "change" }, { scope: "workspace", action: "remove" }, @@ -614,6 +761,85 @@ describe("createPrototypeAuthorizationService() database behavior", () => { ).toEqual([{ event: "workspace.access.remove", targetUserId: first.user.id }]) }) + it.each([{ action: "downgrade" }, { action: "remove" }] as const)( + "rejects a workspace administrator $action that would orphan a child prototype", + async ({ action }) => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: `child-guard-${action}-actor`, + token: `child-guard-${action}-actor-token`, + }) + const target = await createActor(store, { + subject: `child-guard-${action}-target`, + token: `child-guard-${action}-target-token`, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: [ + { userId: actor.user.id, effect: "allow", role: "administrator" }, + { userId: target.user.id, effect: "allow", role: "administrator" }, + ], + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Child prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + + const operation = + action === "downgrade" + ? service.setWorkspaceAccess( + `child-guard-${action}-actor-token`, + workspace.id, + target.user.id, + { effect: "allow", role: "editor" }, + ) + : service.removeWorkspaceAccess( + `child-guard-${action}-actor-token`, + workspace.id, + target.user.id, + ) + + await expect(operation).rejects.toMatchObject({ + status: 409, + code: "last_prototype_administrator", + }) + expect( + await prisma.workspaceAccessGrant.findMany({ + where: { workspaceId: workspace.id }, + orderBy: { userId: "asc" }, + select: { userId: true, effect: true, role: true }, + }), + ).toEqual( + [ + { userId: actor.user.id, effect: "allow", role: "administrator" }, + { userId: target.user.id, effect: "allow", role: "administrator" }, + ].sort((left, right) => left.userId.localeCompare(right.userId)), + ) + expect( + await prisma.prototypeAccessGrant.findMany({ + where: { prototypeId: prototype.id }, + select: { userId: true, effect: true, role: true }, + }), + ).toEqual([{ userId: actor.user.id, effect: "allow", role: "viewer" }]) + expect(await prisma.securityAudit.count()).toBe(0) + }, + ) + it("lets exactly one prototype-direct administrator relinquish authority", async () => { const { prisma, service, store } = await database() const first = await createActor(store, { diff --git a/hub/server/routes/product/index.test.ts b/hub/server/routes/product/index.test.ts index a8e195d0..bc77fd73 100644 --- a/hub/server/routes/product/index.test.ts +++ b/hub/server/routes/product/index.test.ts @@ -76,6 +76,130 @@ async function serve(authentication: AuthenticationRoutes, logger = createLogger } describe("product routes", () => { + it.each([ + { + method: "GET", + path: "/api/workspaces?cursor=workspace-cursor&limit=11", + call: "listWorkspaces", + expectedArguments: [sessionToken, { cursor: "workspace-cursor", limit: "11" }], + }, + { + method: "POST", + path: "/api/workspaces", + call: "createWorkspace", + body: { name: "Mapped workspace" }, + expectedArguments: [sessionToken, { name: "Mapped workspace" }], + }, + { + method: "GET", + path: "/api/workspaces/workspace-123", + call: "getWorkspace", + expectedArguments: [sessionToken, "workspace-123"], + }, + { + method: "PUT", + path: "/api/workspaces/workspace-123/access/user-789", + call: "setWorkspaceAccess", + body: { effect: "allow", role: "commenter" }, + expectedArguments: [ + sessionToken, + "workspace-123", + "user-789", + { effect: "allow", role: "commenter" }, + ], + }, + { + method: "DELETE", + path: "/api/workspaces/workspace-123/access/user-789", + call: "removeWorkspaceAccess", + expectedArguments: [sessionToken, "workspace-123", "user-789"], + }, + { + method: "GET", + path: "/api/workspaces/workspace-123/prototypes?cursor=prototype-cursor&limit=22", + call: "listPrototypes", + expectedArguments: [ + sessionToken, + "workspace-123", + { cursor: "prototype-cursor", limit: "22" }, + ], + }, + { + method: "POST", + path: "/api/workspaces/workspace-123/prototypes", + call: "createPrototype", + body: { name: "Mapped prototype" }, + expectedArguments: [sessionToken, "workspace-123", { name: "Mapped prototype" }], + }, + { + method: "GET", + path: "/api/prototypes/prototype-456", + call: "getPrototype", + expectedArguments: [sessionToken, "prototype-456"], + }, + { + method: "POST", + path: "/api/prototypes/prototype-456/archive", + call: "archivePrototype", + expectedArguments: [sessionToken, "prototype-456"], + }, + { + method: "GET", + path: "/api/prototypes/prototype-456/access/explanation", + call: "explainPrototypeAccess", + expectedArguments: [sessionToken, "prototype-456"], + }, + { + method: "PUT", + path: "/api/prototypes/prototype-456/access/user-789", + call: "setPrototypeAccess", + body: { effect: "deny" }, + expectedArguments: [sessionToken, "prototype-456", "user-789", { effect: "deny" }], + }, + { + method: "DELETE", + path: "/api/prototypes/prototype-456/access/user-789", + call: "removePrototypeAccess", + expectedArguments: [sessionToken, "prototype-456", "user-789"], + }, + { + method: "GET", + path: "/api/prototypes/prototype-456/versions?cursor=version-cursor&limit=33", + call: "listVersions", + expectedArguments: [sessionToken, "prototype-456", { cursor: "version-cursor", limit: "33" }], + }, + { + method: "POST", + path: "/api/prototypes/prototype-456/versions", + call: "createVersion", + body: { label: "Mapped version" }, + expectedArguments: [sessionToken, "prototype-456", { label: "Mapped version" }], + }, + { + method: "GET", + path: "/api/prototypes/prototype-456/versions/version-012", + call: "getVersion", + expectedArguments: [sessionToken, "prototype-456", "version-012"], + }, + ] as const)( + "maps $method $path to $call with the exact request values", + async ({ method, path, call, body, expectedArguments }) => { + const authentication = runtime({ + [call]: vi.fn().mockResolvedValue({}), + }) + const baseUrl = await serve(authentication) + const mutation = method !== "GET" + + await fetch(`${baseUrl}${path}`, { + method, + headers: mutation ? protectedHeaders : { cookie: `__Host-replica-session=${sessionToken}` }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + + expect(authentication.prototypeAuthorization[call].mock.calls).toEqual([expectedArguments]) + }, + ) + it.each([ { method: "GET", path: "/api/workspaces", call: "listWorkspaces" }, { method: "POST", path: "/api/workspaces", call: "createWorkspace", body: { name: "W" } }, From 6c0891a13ac459e3bd6490088dcf790b9310a0f5 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 07:52:22 -0700 Subject: [PATCH 08/10] fix(hub): scope authorization cursors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/docs/prototype-authorization.md | 2 +- .../create-service/index.db.test.ts | 158 +++++++++++++++++ .../create-service/index.db.test.ts | 165 ++++++++++++++++++ .../create-service/index.ts | 32 ++++ 4 files changed, 356 insertions(+), 1 deletion(-) diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md index 31c7aaa8..83c7bf01 100644 --- a/hub/docs/prototype-authorization.md +++ b/hub/docs/prototype-authorization.md @@ -60,7 +60,7 @@ The product API uses: - `GET` and `POST /api/prototypes/:prototypeId/versions` - `GET /api/prototypes/:prototypeId/versions/:versionId` -List routes use an opaque UUID cursor and a limit from 1 through 100. Invalid pagination returns an explicit error. State-changing requests require the configured origin and matching CSRF cookie and header. Product responses are private and non-cacheable, request payloads are omitted from logs, and mutation routes use a bounded rate limit. +List routes use an opaque UUID cursor and a limit from 1 through 100. Prototype and version cursors belong to their owning workspace or prototype; a cursor from another scope is invalid. Invalid pagination returns `422 invalid_pagination`, while malformed or missing path resources return their resource-specific `404` error. State-changing requests require the configured origin and matching CSRF cookie and header. Product responses are private and non-cacheable, request payloads are omitted from logs, and mutation routes use a bounded rate limit. Every product route restores the current session and active installation membership before evaluating product access. Workspace creation and direct access changes also require identity verification from the last five minutes. Mutations repeat the membership check inside their database transaction so a suspended or removed actor cannot complete a stale request. diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index f6dfdb29..d1f0e9cd 100644 --- a/hub/server/domains/administration/create-service/index.db.test.ts +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -634,4 +634,162 @@ describe("createAdministrationService() database behavior", () => { }, ]) }) + + it("does not count a workspace administrator denied on the prototype as a replacement", async () => { + const { prisma, store } = await database() + const actor = await createAdministrator(store, "denied-actor", "denied-actor-token") + const target = await createAdministrator(store, "denied-target", "denied-target-token") + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: target.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "deny", + }, + { + userId: target.user.id, + effect: "allow", + role: "administrator", + }, + ], + }, + }, + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("denied-actor-token", target.membership.id, { + status: "suspended", + }), + ).rejects.toMatchObject({ + status: 409, + code: "product_administration_orphaned", + message: "Transfer prototype administration before changing this membership.", + }) + + await prisma.prototypeAccessGrant.delete({ + where: { + prototypeId_userId: { + prototypeId: prototype.id, + userId: actor.user.id, + }, + }, + }) + await expect( + service.changeMembership("denied-actor-token", target.membership.id, { + status: "suspended", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + status: "suspended", + }) + }) + + it("requires active replacements for every prototype a removed member administers", async () => { + const { prisma, store } = await database() + const actor = await createAdministrator(store, "multi-scope-actor", "multi-scope-actor-token") + const target = await createAdministrator( + store, + "multi-scope-target", + "multi-scope-target-token", + ) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + { + userId: target.user.id, + effect: "allow", + role: "viewer", + }, + ], + }, + }, + }) + const prototypes = await Promise.all( + ["First prototype", "Second prototype"].map((name, index) => + prisma.prototype.create({ + data: { + name, + workspaceId: workspace.id, + createdByUserId: target.user.id, + grants: { + create: [ + { + userId: actor.user.id, + effect: "allow", + role: index === 0 ? "administrator" : "viewer", + }, + { + userId: target.user.id, + effect: "allow", + role: "administrator", + }, + ], + }, + }, + }), + ), + ) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("multi-scope-actor-token", target.membership.id, { + status: "removed", + }), + ).rejects.toMatchObject({ + status: 409, + code: "product_administration_orphaned", + }) + expect(await store.read.session(digestSecret("multi-scope-target-token"))).toBeTruthy() + + await prisma.prototypeAccessGrant.update({ + where: { + prototypeId_userId: { + prototypeId: prototypes[1]!.id, + userId: actor.user.id, + }, + }, + data: { role: "administrator" }, + }) + await expect( + service.changeMembership("multi-scope-actor-token", target.membership.id, { + status: "removed", + }), + ).resolves.toMatchObject({ + userId: target.user.id, + status: "removed", + }) + expect(await store.read.session(digestSecret("multi-scope-target-token"))).toBeNull() + }) }) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 1d1ec9b7..741f1c4e 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -1382,6 +1382,32 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }) }) + it.each([ + { + resource: "malformed workspace", + operation: (service: ReturnType, token: string) => + service.listPrototypes(token, "not-a-workspace-id", {}), + code: "workspace_not_found", + }, + { + resource: "missing prototype", + operation: (service: ReturnType, token: string) => + service.listVersions(token, "00000000-0000-4000-8000-000000000099", {}), + code: "prototype_not_found", + }, + ])("returns a resource 404 for a $resource path ID", async ({ operation, code }) => { + const { service, store } = await database() + await createActor(store, { + subject: `path-${code}`, + token: `path-${code}-token`, + }) + + await expect(operation(service, `path-${code}-token`)).rejects.toMatchObject({ + status: 404, + code, + }) + }) + it("continues workspace pagination after access to the prior cursor is revoked", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { @@ -1878,6 +1904,67 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(new Set([...first.items, ...second.items].map(({ id }) => id)).size).toBe(4) }) + it("rejects a prototype cursor from another workspace", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "cross-workspace-cursor", + token: "cross-workspace-cursor-token", + }) + const workspaces = await Promise.all( + ["First workspace", "Second workspace"].map((name) => + prisma.workspace.create({ + data: { + name, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }), + ), + ) + const firstWorkspacePrototypeIds = [ + "00000000-0000-4000-8000-000000000011", + "00000000-0000-4000-8000-000000000013", + ] + await prisma.prototype.createMany({ + data: [ + ...firstWorkspacePrototypeIds.map((id) => ({ + id, + name: `First workspace prototype ${id.at(-1)}`, + workspaceId: workspaces[0]!.id, + createdByUserId: actor.user.id, + })), + { + id: "00000000-0000-4000-8000-000000000012", + name: "Foreign cursor prototype", + workspaceId: workspaces[1]!.id, + createdByUserId: actor.user.id, + }, + ], + }) + + await expect( + service.listPrototypes("cross-workspace-cursor-token", workspaces[0]!.id, { + cursor: "00000000-0000-4000-8000-000000000012", + limit: 1, + }), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_pagination", + }) + const page = (await service.listPrototypes( + "cross-workspace-cursor-token", + workspaces[0]!.id, + {}, + )) as { items: { id: string }[] } + expect(page.items.map(({ id }) => id)).toEqual(firstWorkspacePrototypeIds) + }) + it("lists immutable versions by sequence and paginates in that order", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { @@ -1954,6 +2041,84 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(second.nextCursor).toBeNull() }) + it("rejects a version cursor from another prototype", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "cross-prototype-cursor", + token: "cross-prototype-cursor-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + const prototypes = await Promise.all( + ["First prototype", "Second prototype"].map((name) => + prisma.prototype.create({ + data: { + name, + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }), + ), + ) + const firstPrototypeVersionIds = [ + "00000000-0000-4000-8000-000000000021", + "00000000-0000-4000-8000-000000000022", + ] + await prisma.prototypeVersion.createMany({ + data: [ + { + id: firstPrototypeVersionIds[0], + prototypeId: prototypes[0]!.id, + createdByUserId: actor.user.id, + sequence: 1, + label: "First version", + }, + { + id: firstPrototypeVersionIds[1], + prototypeId: prototypes[0]!.id, + createdByUserId: actor.user.id, + sequence: 2, + label: "Second version", + }, + ], + }) + const foreignVersion = await prisma.prototypeVersion.create({ + data: { + prototypeId: prototypes[1]!.id, + createdByUserId: actor.user.id, + sequence: 1, + label: "Foreign cursor version", + }, + }) + + await expect( + service.listVersions("cross-prototype-cursor-token", prototypes[0]!.id, { + cursor: foreignVersion.id, + limit: 1, + }), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_pagination", + }) + const page = (await service.listVersions( + "cross-prototype-cursor-token", + prototypes[0]!.id, + {}, + )) as { items: { id: string }[] } + expect(page.items.map(({ id }) => id)).toEqual(firstPrototypeVersionIds) + }) + it("creates sequential immutable versions and rejects additions after archival", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index c24e7ebb..a7cfc2b2 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -53,6 +53,36 @@ function parsePage(query: unknown) { return parsed.data } +async function requirePrototypeCursor( + database: PrismaClient, + workspaceId: string, + cursor?: string, +): Promise { + if (!cursor) return + const prototype = await database.prototype.findUnique({ + where: { id: cursor }, + select: { workspaceId: true }, + }) + if (prototype?.workspaceId !== workspaceId) { + throw new AuthenticationError(422, "invalid_pagination", "Pagination is invalid.") + } +} + +async function requireVersionCursor( + database: PrismaClient, + prototypeId: string, + cursor?: string, +): Promise { + if (!cursor) return + const version = await database.prototypeVersion.findUnique({ + where: { id: cursor }, + select: { prototypeId: true }, + }) + if (version?.prototypeId !== prototypeId) { + throw new AuthenticationError(422, "invalid_pagination", "Pagination is invalid.") + } +} + function parseName(input: unknown, code: string, message: string) { const parsed = nameSchema.safeParse(input) if (!parsed.success) throw new AuthenticationError(422, code, message) @@ -619,6 +649,7 @@ export function createPrototypeAuthorizationService({ const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) const { workspace } = await workspaceDecision(database, actor.userId, workspaceId) + await requirePrototypeCursor(database, workspaceId, cursor) const workspaceGrant = workspace.grants[0] ?? null if ( !(await hasAnyPrototypeAccess({ @@ -806,6 +837,7 @@ export function createPrototypeAuthorizationService({ const actor = await currentSession(store, sessionToken) const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) requireDecision(decision, "viewer") + await requireVersionCursor(database, prototypeId, cursor) const items = await database.prototypeVersion.findMany({ where: { prototypeId }, orderBy: { sequence: "asc" }, From 74797bf5c650b0370ba7d8fe7c4131951c5fa13d Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 09:22:58 -0700 Subject: [PATCH 09/10] fix(hub): preserve scoped cursor authorization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.test.ts | 2 +- .../administration/create-service/index.ts | 1 + .../domains/identity/create-store/types.ts | 1 + .../domains/identity/with-store-lock/index.ts | 4 +- .../create-service/index.db.test.ts | 125 ++++++++++++++++++ .../create-service/index.ts | 17 ++- 6 files changed, 141 insertions(+), 9 deletions(-) diff --git a/hub/server/domains/administration/create-service/index.test.ts b/hub/server/domains/administration/create-service/index.test.ts index be319663..1bf6838e 100644 --- a/hub/server/domains/administration/create-service/index.test.ts +++ b/hub/server/domains/administration/create-service/index.test.ts @@ -74,7 +74,7 @@ function service(identityRecords: ReturnType) { const typedRecords = identityRecords as unknown as IdentityRecords const store: IdentityStore = { read: typedRecords, - lock: (operation) => operation(typedRecords), + lock: (operation) => operation(typedRecords, {} as never), } return createAdministrationService({ store, now: () => now }) } diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts index 92538492..bb38952c 100644 --- a/hub/server/domains/administration/create-service/index.ts +++ b/hub/server/domains/administration/create-service/index.ts @@ -6,6 +6,7 @@ import type { } from "#server/domains/administration/create-service/types" import { getCurrentSession } from "#server/domains/authentication/get-current-session" import type { IdentityRecords } from "#server/domains/identity/create-store/types" +import { ensureProductAdministrationContinuity } from "#server/domains/prototype-authorization/ensure-administration-continuity" import { AuthenticationError } from "#server/utils/authentication-error" const invitationSchema = z.object({ diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 3f4770d1..952d0d8c 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -18,6 +18,7 @@ import type { } from "#server/repositories/membership-invitations/types" import type { AuditInput, SecurityAuditRecord } from "#server/repositories/security-audits/types" import type { HubSession } from "#server/repositories/sessions/types" +import type { Prisma } from "../../../generated/prisma/client.ts" import type { HubUser, UserInput } from "#server/repositories/users/types" import type { IdentityStoreState } from "#server/repositories/identity-store-state/types" diff --git a/hub/server/domains/identity/with-store-lock/index.ts b/hub/server/domains/identity/with-store-lock/index.ts index 02cd89ef..0af6291e 100644 --- a/hub/server/domains/identity/with-store-lock/index.ts +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -3,11 +3,11 @@ import { ensureGitHubIntegrationCredentials } from "#server/repositories/github- import { withIdentityStoreLock as withRepositoryLock } from "#server/repositories/identity-store-state/with-store-lock" import type { IdentityRecords } from "#server/domains/identity/create-store/types" -import type { PrismaClient } from "../../../generated/prisma/client.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" export function withIdentityStoreLock( client: PrismaClient, - operation: (records: IdentityRecords) => Promise, + operation: (records: IdentityRecords, database: Prisma.TransactionClient) => Promise, ): Promise { return withRepositoryLock(client, async (database) => { await ensureGitHubIntegrationCredentials(database) diff --git a/hub/server/domains/prototype-authorization/create-service/index.db.test.ts b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts index 741f1c4e..321650a1 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 @@ -1965,6 +1965,82 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(page.items.map(({ id }) => id)).toEqual(firstWorkspacePrototypeIds) }) + it("accepts a scoped prototype cursor with an uppercase workspace ID", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "uppercase-workspace-cursor", + token: "uppercase-workspace-cursor-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + const prototypes = await Promise.all( + ["First", "Second"].map((name) => + prisma.prototype.create({ + data: { + name, + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }), + ), + ) + + await expect( + service.listPrototypes("uppercase-workspace-cursor-token", workspace.id.toUpperCase(), { + cursor: prototypes[0]!.id, + limit: 1, + }), + ).resolves.toMatchObject({ + items: [{ id: prototypes[1]!.id }], + nextCursor: null, + }) + }) + + it("does not reveal prototype cursor ownership to an unauthorized member", async () => { + const { prisma, service, store } = await database() + const owner = await createActor(store, { + subject: "cursor-owner", + token: "cursor-owner-token", + }) + await createActor(store, { + subject: "cursor-outsider", + token: "cursor-outsider-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: owner.user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: owner.user.id, + }, + }) + + for (const cursor of [prototype.id, "00000000-0000-4000-8000-000000000099"]) { + await expect( + service.listPrototypes("cursor-outsider-token", workspace.id, { cursor }), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + } + }) + it("lists immutable versions by sequence and paginates in that order", async () => { const { prisma, service, store } = await database() const actor = await createActor(store, { @@ -2119,6 +2195,55 @@ describe("createPrototypeAuthorizationService() database behavior", () => { expect(page.items.map(({ id }) => id)).toEqual(firstPrototypeVersionIds) }) + it("accepts a scoped version cursor with an uppercase prototype ID", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "uppercase-prototype-cursor", + token: "uppercase-prototype-cursor-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + const versions = await Promise.all( + [1, 2].map((sequence) => + prisma.prototypeVersion.create({ + data: { + prototypeId: prototype.id, + createdByUserId: actor.user.id, + sequence, + }, + }), + ), + ) + + await expect( + service.listVersions("uppercase-prototype-cursor-token", prototype.id.toUpperCase(), { + cursor: versions[0]!.id, + limit: 1, + }), + ).resolves.toMatchObject({ + items: [{ id: versions[1]!.id }], + nextCursor: null, + }) + }) + it("creates sequential immutable versions and rejects additions after archival", async () => { const { prisma, service, store } = await database() await createActor(store, { diff --git a/hub/server/domains/prototype-authorization/create-service/index.ts b/hub/server/domains/prototype-authorization/create-service/index.ts index a7cfc2b2..3563da47 100644 --- a/hub/server/domains/prototype-authorization/create-service/index.ts +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -649,24 +649,24 @@ export function createPrototypeAuthorizationService({ const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) const { workspace } = await workspaceDecision(database, actor.userId, workspaceId) - await requirePrototypeCursor(database, workspaceId, cursor) const workspaceGrant = workspace.grants[0] ?? null if ( !(await hasAnyPrototypeAccess({ baseline, database, userId: actor.userId, - workspaceId, + workspaceId: workspace.id, workspaceGrant, })) ) { throw new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied.") } + await requirePrototypeCursor(database, workspace.id, cursor) return readAccessiblePrototypePage({ baseline, database, userId: actor.userId, - workspaceId, + workspaceId: workspace.id, workspaceGrant, cursor, limit, @@ -835,11 +835,16 @@ export function createPrototypeAuthorizationService({ async listVersions(sessionToken, prototypeId, query) { const { cursor, limit } = parsePage(query) const actor = await currentSession(store, sessionToken) - const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + database, + actor.userId, + prototypeId, + ) requireDecision(decision, "viewer") - await requireVersionCursor(database, prototypeId, cursor) + await requireVersionCursor(database, prototype.id, cursor) const items = await database.prototypeVersion.findMany({ - where: { prototypeId }, + where: { prototypeId: prototype.id }, orderBy: { sequence: "asc" }, ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), take: limit + 1, From ae7731a73f2ecb96cfe35683cf7933e325bbda7d Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 29 Oct 2026 09:33:49 -0700 Subject: [PATCH 10/10] test(hub): stabilize cursor pagination case Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../prototype-authorization/create-service/index.db.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 321650a1..98ae7e05 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 @@ -1985,9 +1985,13 @@ describe("createPrototypeAuthorizationService() database behavior", () => { }, }) const prototypes = await Promise.all( - ["First", "Second"].map((name) => + [ + { id: "00000000-0000-4000-8000-000000000031", name: "First" }, + { id: "00000000-0000-4000-8000-000000000032", name: "Second" }, + ].map(({ id, name }) => prisma.prototype.create({ data: { + id, name, workspaceId: workspace.id, createdByUserId: actor.user.id,