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..166c45bc 100644 --- a/hub/docs/membership-and-administration.md +++ b/hub/docs/membership-and-administration.md @@ -9,12 +9,14 @@ 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. | 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: @@ -59,4 +61,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..83c7bf01 --- /dev/null +++ b/hub/docs/prototype-authorization.md @@ -0,0 +1,75 @@ +# 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. 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. + +## 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. 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. + +Suspending or removing a membership fails with `product_administration_orphaned` when that user is the final active administrator for a workspace or prototype. An administrator must transfer product administration first. Changing an active installation administrator to a member does not remove an explicit product role, because installation and product authority remain separate. + +A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. Workspace list responses contain only workspace fields and never embed prototype or access-rule metadata. + +## 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. 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. + +## 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..83765b5b 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, 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. 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/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts index 3eed6153..d1f0e9cd 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,332 @@ 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" }, + }, + ]) + }) + + 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/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 809df37f..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({ @@ -272,7 +273,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 +310,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-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/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..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" @@ -128,5 +129,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..0af6291e 100644 --- a/hub/server/domains/identity/with-store-lock/index.ts +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -3,14 +3,14 @@ import { 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) - 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 new file mode 100644 index 00000000..98ae7e05 --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -0,0 +1,2312 @@ +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(baseline?: PrototypeBaselinePolicy) { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { + prisma, + store: createIdentityStore(prisma), + service: createPrototypeAuthorizationService({ + baseline, + 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 } + }) +} + +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() + 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("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 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" }, + { 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.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" }) + 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("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.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, { + 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.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, { + 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("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, { + 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.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, { + 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, { + 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("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() + .mockRejectedValue(new Error("baseline unavailable")) + const baseline: PrototypeBaselinePolicy = { + role: baselineRole, + } + 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", + }, + }, + }, + }) + const prototypeScan = vi.spyOn(prisma.prototype, "findMany") + + await expect( + service.listPrototypes("workspace-denied-prototype-list-token", workspace.id, {}), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + expect(prototypeScan).not.toHaveBeenCalled() + 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", + }, + }, + }, + }) + + 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) + }) + + 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("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("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( + [ + { 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, + }, + }), + ), + ) + + 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, { + 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("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("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, { + 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..3563da47 --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -0,0 +1,914 @@ +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, + PrototypeBaselinePolicy, + 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 +} + +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) + 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 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.") + } +} + +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 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, + 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( + baseline: PrototypeBaselinePolicy | null, + 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: await prototypeAccessDecision( + baseline, + userId, + prototype.workspace.grants[0] ?? null, + prototype, + ), + } +} + +async function hasPrototypeAdministratorAfter( + database: Prisma.TransactionClient, + prototypeId: string, + targetUserId: string, + replacement: GrantInput | null, + scope: "prototype" | "workspace" = "prototype", + excludedUserId?: string, +): 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, + ]) + 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 workspaceGrant = + scope === "workspace" && userId === targetUserId + ? replacement + : prototype.workspace.grants.find((grant) => grant.userId === userId) + const prototypeGrant = + 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 + return { + items: pageItems, + nextCursor: hasMore ? (pageItems.at(-1)?.id ?? null) : null, + } +} + +function prototypeDto(prototype: { + id: string + workspaceId: string + createdByUserId: string + name: string + archivedAt: Date | null + createdAt: Date + updatedAt: Date +}) { + 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( + baseline: PrototypeBaselinePolicy | null, + userId: string, + workspaceGrant: GrantInput | null, + prototype: { + id: string + grants: GrantInput[] + }, +) { + const hubDecision = evaluatePrototypeAccess({ + workspaceGrant, + prototypeGrant: prototype.grants[0] ?? null, + }) + if (hubDecision.source !== "none" || !baseline) return hubDecision + return evaluatePrototypeAccess({ + baselineRole: await baseline.role(userId, prototype.id), + }) +} + +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 +}) { + 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, ...(scanCursor ? { id: { gt: scanCursor } } : {}) }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + 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 +}) { + const workspaceDecision = evaluatePrototypeAccess({ workspaceGrant }) + if (workspaceDecision.source !== "none") return workspaceDecision.allowed + if (!baseline) { + return Boolean( + await database.prototype.findFirst({ + where: { + workspaceId, + grants: { some: { userId, effect: "allow" } }, + }, + select: { id: true }, + }), + ) + } + let cursor: string | undefined + while (true) { + const candidates = await database.prototype.findMany({ + where: { workspaceId, ...(cursor ? { id: { gt: cursor } } : {}) }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + for (const candidate of candidates) { + if ((await prototypeAccessDecision(baseline, userId, workspaceGrant, candidate)).allowed) { + return true + } + } + if (candidates.length < 100) return false + cursor = candidates.at(-1)?.id + } +} + +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({ + baseline = null, + database, + store, +}: { + baseline?: PrototypeBaselinePolicy | null + 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: { + ...(cursor ? { id: { gt: cursor } } : {}), + grants: { + some: { userId: actor.userId, effect: "allow" }, + none: { userId: actor.userId, effect: "deny" }, + }, + }, + orderBy: { id: "asc" }, + take: limit + 1, + select: { + id: true, + createdByUserId: true, + name: true, + createdAt: true, + updatedAt: true, + }, + }) + return pageResult(items, limit) + }, + + async createWorkspace(sessionToken, input) { + const { name } = parseName(input, "invalid_workspace", "The workspace is invalid.") + return store.lock(async (_records, 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) + 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(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, targetId) + const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) + if (!(await hasWorkspaceAdministratorAfter(transaction, workspaceId, targetId, change))) { + throw new AuthenticationError( + 409, + "last_workspace_administrator", + "The final workspace administrator cannot be changed.", + ) + } + for (const prototypeId of prototypeIds) { + if ( + !(await hasPrototypeAdministratorAfter( + transaction, + prototypeId, + targetId, + 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: targetId } }, + create: { workspaceId, userId: targetId, ...change }, + update: change, + }) + await audit(transaction, actor, { + event: change.effect === "deny" ? "workspace.access.deny" : "workspace.access.grant", + workspaceId, + targetUserId: targetId, + context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, + }) + return grant + }) + }, + + 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) + 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") + const prototypeIds = await lockWorkspacePrototypes(transaction, workspaceId) + const existing = await transaction.workspaceAccessGrant.findUnique({ + 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, targetId, 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, + targetId, + 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: targetId } }, + }) + await audit(transaction, actor, { + event: "workspace.access.remove", + workspaceId, + targetUserId: targetId, + }) + }) + }, + + async listPrototypes(sessionToken, workspaceId, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + const { workspace } = await workspaceDecision(database, actor.userId, workspaceId) + const workspaceGrant = workspace.grants[0] ?? null + if ( + !(await hasAnyPrototypeAccess({ + baseline, + database, + userId: actor.userId, + 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: workspace.id, + workspaceGrant, + cursor, + limit, + }) + }, + + async createPrototype(sessionToken, workspaceId, input) { + const { name } = parseName(input, "invalid_prototype", "The prototype is invalid.") + return store.lock(async (_records, 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( + baseline, + 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 store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "editor") + if (prototype.archivedAt) { + throw new AuthenticationError( + 409, + "prototype_archived", + "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(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, targetId) + if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, 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: 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: targetId, + context: { reason: change.effect === "deny" ? "explicit_deny" : `role_${change.role}` }, + }) + return grant + }) + }, + + 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") + if (!(await hasPrototypeAdministratorAfter(transaction, prototypeId, targetId, null))) { + throw new AuthenticationError( + 409, + "last_prototype_administrator", + "The final prototype administrator cannot be removed.", + ) + } + const removed = await transaction.prototypeAccessGrant.deleteMany({ + where: { prototypeId, userId: targetId }, + }) + 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: targetId, + }) + }) + }, + + async listVersions(sessionToken, prototypeId, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + const { prototype, decision } = await prototypeDecision( + baseline, + database, + actor.userId, + prototypeId, + ) + requireDecision(decision, "viewer") + await requireVersionCursor(database, prototype.id, cursor) + const items = await database.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { sequence: "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 store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "editor") + if (prototype.archivedAt) { + throw new AuthenticationError( + 409, + "prototype_archived", + "Archived prototypes cannot receive versions.", + ) + } + const latest = await transaction.prototypeVersion.findFirst({ + where: { prototypeId }, + orderBy: { sequence: "desc" }, + }) + const version = await transaction.prototypeVersion.create({ + data: { + prototypeId, + createdByUserId: actor.userId, + sequence: (latest?.sequence ?? 0) + 1, + label: parsed.data.label ?? null, + }, + }) + await audit(transaction, actor, { + event: "prototype.version.create", + workspaceId: prototype.workspaceId, + prototypeId, + prototypeVersionId: version.id, + }) + return version + }) + }, + + async getVersion(sessionToken, prototypeId, versionId) { + const actor = await currentSession(store, sessionToken) + 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({ + 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..fc197973 --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -0,0 +1,51 @@ +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 +} + +export interface PrototypeBaselinePolicy { + role(userId: string, prototypeId: string): Promise +} 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.", + ) + } + } +} 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..bc77fd73 --- /dev/null +++ b/hub/server/routes/product/index.test.ts @@ -0,0 +1,376 @@ +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?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" } }, + { 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 }