diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index ab189513..a6b283e9 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -20,7 +20,17 @@ jobs: fail-fast: false matrix: node: [24, 26] - command: [lint, typecheck, test, build, storybook:build, "db:validate", "test:database"] + command: + [ + lint, + typecheck, + test, + "test:entra-setup", + build, + storybook:build, + "db:validate", + "test:database", + ] services: postgres: image: ${{ matrix.command == 'test:database' && 'postgres:18.6-bookworm' || '' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6760d912..260bce38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,6 +103,7 @@ npm run lint npm run db:validate npm run typecheck npm test +npm run test:entra-setup npm run build npm run storybook:build ``` diff --git a/docs/testing-and-review.md b/docs/testing-and-review.md index 4ffdcbb3..85182986 100644 --- a/docs/testing-and-review.md +++ b/docs/testing-and-review.md @@ -44,7 +44,7 @@ When a command gains a dependency or build prerequisite, update its local setup All CI workflows run on every pull request and push to `main`. Do not skip checks based on changed paths or add jobs that only combine other jobs' results. Configure branch protection to require the individual checks. -Hub reports lint, types, unit tests, database tests, the frontend/API build, Storybook, and Controls/extension checks separately. Only the database test command starts PostgreSQL. The new Hub has no production-image or release jobs. +Hub reports lint, types, unit tests, Entra setup-script tests, database tests, the frontend/API build, Storybook, and Controls/extension checks separately. Only the database test command starts PostgreSQL. The new Hub has no production-image or release jobs. Maintainers must update branch rules when required checks change. Editing a workflow does not update those rules. diff --git a/hub/.devcontainer/compose.yaml b/hub/.devcontainer/compose.yaml index f95cc194..230ade1e 100644 --- a/hub/.devcontainer/compose.yaml +++ b/hub/.devcontainer/compose.yaml @@ -8,6 +8,10 @@ services: working_dir: /workspaces/replica/hub volumes: - ../..:/workspaces/replica:cached + env_file: + - path: default.env + - path: ../.env + required: false environment: DATABASE_URL: postgresql://replica:replica@db:5432/replica TEST_DATABASE_URL: postgresql://replica:replica@db:5432/postgres diff --git a/hub/.devcontainer/default.env b/hub/.devcontainer/default.env new file mode 100644 index 00000000..f4130150 --- /dev/null +++ b/hub/.devcontainer/default.env @@ -0,0 +1 @@ +HUB_AUTH_PROVIDER=github diff --git a/hub/.env.example b/hub/.env.example index ed8cba81..b70043f4 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -4,8 +4,65 @@ PORT=3001 # API logging threshold: trace, debug, info, warn, error, fatal, or silent. LOG_LEVEL=info +# Public browser origin. Production requires HTTPS; local HTTP must use loopback. +HUB_ORIGIN=http://127.0.0.1:5173 + +# Select exactly one user sign-in provider. Hub fails closed when this is absent. +HUB_AUTH_PROVIDER=github + +# Standards-based OIDC. The first documented profile is single-tenant Microsoft Entra ID. +# The credential reference names another environment variable; Hub never stores the secret. +# HUB_OIDC_ENABLED=true +# HUB_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 +# HUB_OIDC_CLIENT_ID= +# HUB_OIDC_CALLBACK_URL=https://hub.example.com/api/auth/oidc/callback +# HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE=HUB_OIDC_CLIENT_SECRET +# HUB_OIDC_CLIENT_SECRET= +# HUB_OIDC_MEMBER_ROLE=Hub.User +# HUB_OIDC_ADMINISTRATOR_ROLE=Hub.Administrator +# HUB_OIDC_SCOPES=openid profile email +# HUB_OIDC_MEMBERSHIP_POLICY=jit-assignment +# HUB_OIDC_PROVIDER_REVISION=1 + +# Keep 0 unless the API is reachable only through this exact number of trusted proxies. +HUB_TRUSTED_PROXY_HOPS=0 + # Local development only. The devcontainer supplies its own database hostname. DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica # Only database-enabled tests require this URL and CREATEDB permission. TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres + +# Local reference artifact storage. The default is ~/.replica/artifacts, outside the checkout. +# Production deployments should mount durable storage here. +# HUB_ARTIFACT_STORAGE_PATH=/var/lib/replica/artifacts + +# GitHub user authorization is optional until both values are configured. +# These credentials establish user identity and are separate from source integration. +# HUB_GITHUB_AUTH_CLIENT_ID= +# HUB_GITHUB_AUTH_CLIENT_SECRET= + +# GitHub proves identity by default. github-synchronized admits users through explicitly +# linked repository sources. legacy-repository preserves the earlier compatibility behavior. +HUB_ADMISSION_POLICY=hub-managed + +# Generate separate host-held values. Bootstrap can enroll the first administrator once. +# Recovery works only when no active administrator remains and never reopens bootstrap. +# HUB_BOOTSTRAP_SECRET= +# HUB_ADMIN_RECOVERY_SECRET= + +# Select the GitHub source-integration credential provider. See docs/identity.md. +HUB_GITHUB_CREDENTIAL_PROVIDER=database + +# Generate separate values outside the database. See docs/identity.md. +# HUB_GITHUB_CREDENTIAL_KEY= + +# The environment provider is read-only and requires every source-integration setting below. +# GITHUB_APP_ID= +# GITHUB_INSTALLATION_ID= +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= +# GITHUB_PRIVATE_KEY= + +# Required only for /api/github/webhooks. Keep it separate from user-auth credentials. +# HUB_GITHUB_WEBHOOK_SECRET= diff --git a/hub/.prettierignore b/hub/.prettierignore index 5c018a01..bb7de867 100644 --- a/hub/.prettierignore +++ b/hub/.prettierignore @@ -1,6 +1,7 @@ /log .local .npm +.devcontainer/default.env .storybook/instances .storybook/settings.json node_modules diff --git a/hub/README.md b/hub/README.md index fba50194..13b111e8 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,9 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API has one route: `GET /api/health`. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API. +The API exposes its health check, GitHub or production OIDC authentication, sessions, installation membership administration, prototype authorization, opt-in GitHub-synchronized admission and prototype baselines, and provider-neutral artifact publishing. The frontend provides configured-provider sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. Sign-in, saved data, live updates, background jobs, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. One configured GitHub or OIDC provider proves user identity. Hub-owned admission, optional GitHub-synchronized authorization with Hub overrides, invitations, membership roles and lifecycle, administrator recovery, session revocation, prototype metadata, and artifact ingestion are implemented. Previews, reviews, SCIM, live updates, general background-job infrastructure, and deployment are not implemented. Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior. diff --git a/hub/app/javascript/application/authentication/index.test.tsx b/hub/app/javascript/application/authentication/index.test.tsx new file mode 100644 index 00000000..6e49a8a3 --- /dev/null +++ b/hub/app/javascript/application/authentication/index.test.tsx @@ -0,0 +1,175 @@ +import { ThemeProvider } from "@primer/react-brand" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { AuthenticationApplication } from "." + +describe("AuthenticationApplication()", () => { + it("offers provider-role recovery through the configured sign-in flow", async () => { + window.history.replaceState({}, "", "/?authentication=failed&reason=provider_role_changed") + const authorizationUrl = "#provider-role-recovery" + const session = { + authenticated: true, + configured: true, + provider: "oidc", + providerLabel: "Company SSO", + csrfToken: "session-csrf-token", + user: { id: "administrator", displayName: "Hub administrator" }, + membership: { id: "administrator-membership", role: "administrator", status: "active" }, + } + const administration = { memberships: [], invitations: [] } + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (input === "/api/session" && init?.method === undefined) { + return Promise.resolve(Response.json(session)) + } + if (input === "/api/administration" && init?.method === undefined) { + return Promise.resolve(Response.json(administration)) + } + if (input === "/api/auth/oidc") { + return Promise.resolve(Response.json({ authorizationUrl })) + } + return Promise.reject(new Error("Unexpected request")) + }) + vi.stubGlobal("fetch", fetch) + render( + + + , + ) + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your provider role changed. Sign in again to update your Hub access.", + ) + fireEvent.click(screen.getByRole("button", { name: "Sign in again" })) + + await waitFor(() => expect(window.location.hash).toBe(authorizationUrl)) + expect(fetch).toHaveBeenCalledWith("/api/auth/oidc", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": "session-csrf-token" }, + }) + }) + + it("starts fresh verification instead of presenting a failed administration mutation as success", async () => { + const authorizationUrl = "#provider-authorization" + const session = { + authenticated: true, + configured: true, + provider: "github", + providerLabel: "GitHub", + csrfToken: "session-csrf-token", + user: { id: "administrator", displayName: "Hub administrator" }, + membership: { id: "administrator-membership", role: "administrator", status: "active" }, + } + const administration = { + memberships: [ + { + id: "member-membership", + status: "active", + role: "member", + user: { + id: "member", + displayName: "Hub member", + externalIdentities: [], + }, + }, + ], + invitations: [], + } + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (input === "/api/session" && init?.method === undefined) { + return Promise.resolve(Response.json(session)) + } + if (input === "/api/administration" && init?.method === undefined) { + return Promise.resolve(Response.json(administration)) + } + if (input === "/api/administration/memberships/member-membership") { + return Promise.resolve( + Response.json( + { error: "fresh_authorization_required", message: "Verify before continuing." }, + { status: 403 }, + ), + ) + } + if (input === "/api/session/verify") { + return Promise.resolve(Response.json({ authorizationUrl })) + } + return Promise.reject(new Error("Unexpected request")) + }) + vi.stubGlobal("fetch", fetch) + render( + + + , + ) + const member = (await screen.findByText("Hub member")).closest("li")! + + fireEvent.click(screen.getByRole("button", { name: "Suspend" })) + + await waitFor(() => expect(window.location.hash).toBe(authorizationUrl)) + expect(fetch).toHaveBeenCalledWith("/api/session/verify", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": "session-csrf-token" }, + }) + expect(member).toHaveTextContent("member · active") + expect(fetch.mock.calls.filter(([input]) => input === "/api/session")).toHaveLength(1) + }) + + it("shows a useful error when fresh verification cannot start", async () => { + const session = { + authenticated: true, + configured: true, + provider: "github", + providerLabel: "GitHub", + csrfToken: "session-csrf-token", + user: { id: "administrator", displayName: "Hub administrator" }, + membership: { id: "administrator-membership", role: "administrator", status: "active" }, + } + const administration = { + memberships: [ + { + id: "member-membership", + status: "active", + role: "member", + user: { + id: "member", + displayName: "Hub member", + externalIdentities: [], + }, + }, + ], + invitations: [], + } + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (input === "/api/session" && init?.method === undefined) { + return Promise.resolve(Response.json(session)) + } + if (input === "/api/administration" && init?.method === undefined) { + return Promise.resolve(Response.json(administration)) + } + if (input === "/api/administration/memberships/member-membership") { + return Promise.resolve( + Response.json({ error: "fresh_authorization_required" }, { status: 403 }), + ) + } + if (input === "/api/session/verify") { + return Promise.resolve(Response.json({ error: "provider_unavailable" }, { status: 503 })) + } + return Promise.reject(new Error("Unexpected request")) + }) + vi.stubGlobal("fetch", fetch) + render( + + + , + ) + await screen.findByText("Hub member") + const currentUrl = window.location.href + + fireEvent.click(screen.getByRole("button", { name: "Suspend" })) + + expect(await screen.findByRole("alert")).toHaveTextContent("Hub could not start verification.") + expect(window.location.href).toBe(currentUrl) + }) +}) diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx new file mode 100644 index 00000000..2e0120ce --- /dev/null +++ b/hub/app/javascript/application/authentication/index.tsx @@ -0,0 +1,214 @@ +import { useEffect, useState } from "react" + +import { SignInPage } from "@pages/sign-in-page" +import type { SignInPageUser } from "@pages/sign-in-page/types" +import { MembershipAdministrationPage } from "@pages/membership-administration-page" +import type { + InvitationRecord, + MembershipRecord, +} from "@pages/membership-administration-page/types" + +interface SessionResponse { + authenticated: boolean + configured: boolean + provider: "github" | "oidc" | null + providerLabel: string | null + csrfToken: string + user?: SignInPageUser + membership?: { + id: string + role: "member" | "administrator" + status: "active" + } +} + +interface AdministrationResponse { + memberships: MembershipRecord[] + invitations: InvitationRecord[] +} + +async function fetchSession(): Promise { + const response = await fetch("/api/session", { credentials: "same-origin" }) + if (!response.ok) throw new Error("Session request failed") + return (await response.json()) as SessionResponse +} + +export function AuthenticationApplication() { + const [session, setSession] = useState(null) + const [error, setError] = useState(() => + new URLSearchParams(window.location.search).get("reason") === "provider_role_changed" + ? "Your provider role changed. Sign in again to update your Hub access." + : null, + ) + const [loading, setLoading] = useState(true) + const [administration, setAdministration] = useState(null) + + useEffect(() => { + fetchSession() + .then(async (value) => { + setSession(value) + if (value.membership?.role === "administrator") { + const response = await fetch("/api/administration", { credentials: "same-origin" }) + if (!response.ok) throw new Error("Administration request failed") + setAdministration((await response.json()) as AdministrationResponse) + } + }) + .catch(() => setError("Hub could not load your sign-in status.")) + .finally(() => setLoading(false)) + }, []) + + async function signIn() { + if (!session) return + setLoading(true) + setError(null) + try { + if (!session.provider) throw new Error("Sign-in provider is not configured") + const response = await fetch(`/api/auth/${session.provider}`, { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!response.ok) throw new Error("Sign-in request failed") + const data = (await response.json()) as { authorizationUrl: string } + window.location.assign(data.authorizationUrl) + } catch { + setError("Hub could not start sign-in.") + setLoading(false) + } + } + + async function startEnrollment(purpose: "bootstrap" | "recovery", secret: string) { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch(`/api/auth/${purpose}`, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": session.csrfToken, + }, + body: JSON.stringify({ secret }), + }) + if (!response.ok) throw new Error("Enrollment is unavailable.") + const data = (await response.json()) as { authorizationUrl: string } + window.location.assign(data.authorizationUrl) + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Enrollment is unavailable.") + setLoading(false) + } + } + + async function signOut() { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch("/api/session", { + method: "DELETE", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!response.ok) throw new Error("Sign-out request failed") + setSession(await fetchSession()) + setAdministration(null) + } catch { + setError("Hub could not sign you out.") + } finally { + setLoading(false) + } + } + + async function startVerification() { + if (!session) throw new Error("Session is unavailable.") + const response = await fetch("/api/session/verify", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!response.ok) throw new Error("Hub could not start verification.") + const data = (await response.json()) as { authorizationUrl: string } + window.location.assign(data.authorizationUrl) + } + + async function updateAdministration(path: string, method: string, body?: unknown) { + if (!session) return + setLoading(true) + setError(null) + try { + const response = await fetch(path, { + method, + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": session.csrfToken, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + if (!response.ok) { + const data = (await response.json()) as { error?: string; message?: string } + if (data.error === "fresh_authorization_required") { + await startVerification() + return + } + throw new Error(data.message ?? data.error ?? "Administration request failed") + } + const current = await fetchSession() + setSession(current) + if (current.membership?.role === "administrator") { + const refreshed = await fetch("/api/administration", { credentials: "same-origin" }) + if (!refreshed.ok) throw new Error("Administration request failed") + setAdministration((await refreshed.json()) as AdministrationResponse) + } else { + setAdministration(null) + } + } catch (requestError) { + setError( + requestError instanceof Error ? requestError.message : "Hub could not update membership.", + ) + } finally { + setLoading(false) + } + } + + if (session?.authenticated && session.membership?.role === "administrator" && administration) { + return ( + + void updateAdministration("/api/administration/invitations", "POST", input) + } + onChange={(membershipId, input) => + void updateAdministration( + `/api/administration/memberships/${membershipId}`, + "PATCH", + input, + ) + } + onRevokeInvitation={(invitationId) => + void updateAdministration(`/api/administration/invitations/${invitationId}`, "DELETE") + } + onSignIn={() => void signIn()} + onSignOut={() => void signOut()} + /> + ) + } + + return ( + void signIn()} + onSignOut={() => void signOut()} + onBootstrap={(secret) => void startEnrollment("bootstrap", secret)} + onRecovery={(secret) => void startEnrollment("recovery", secret)} + /> + ) +} diff --git a/hub/app/javascript/entrypoints/application.tsx b/hub/app/javascript/entrypoints/application.tsx index ca589d06..fe075efb 100644 --- a/hub/app/javascript/entrypoints/application.tsx +++ b/hub/app/javascript/entrypoints/application.tsx @@ -2,7 +2,7 @@ import "@/tokens/base.css" import { StrictMode } from "react" import { createRoot } from "react-dom/client" -import { ReplicaLogo } from "@components/replica-logo" +import { AuthenticationApplication } from "@/application/authentication" import { HubThemeProvider } from "@hooks/use-hub-theme" const root = document.getElementById("root") @@ -11,7 +11,7 @@ if (!root) throw new Error("The Hub document is missing its React root.") createRoot(root).render( - + , ) diff --git a/hub/app/javascript/pages/membership-administration-page/index.test.tsx b/hub/app/javascript/pages/membership-administration-page/index.test.tsx new file mode 100644 index 00000000..1d0d2373 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.test.tsx @@ -0,0 +1,77 @@ +import { ThemeProvider } from "@primer/react-brand" +import { fireEvent, render, screen, within } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { MembershipAdministrationPage } from "." + +describe("MembershipAdministrationPage()", () => { + it("lets an administrator sign in again", () => { + const onSignIn = vi.fn() + render( + + + , + ) + + fireEvent.click(screen.getByRole("button", { name: "Sign in again" })) + + expect(onSignIn).toHaveBeenCalledOnce() + }) + + it("offers restoration for suspension but no lifecycle actions after removal", () => { + const onChange = vi.fn() + render( + + + , + ) + + const suspended = screen.getByText("Suspended member").closest("li")! + fireEvent.click(within(suspended).getByRole("button", { name: "Restore" })) + expect(onChange).toHaveBeenCalledWith("suspended", { status: "active" }) + + const removed = screen.getByText("Removed administrator").closest("li")! + expect(within(removed).queryByRole("button")).not.toBeInTheDocument() + }) +}) diff --git a/hub/app/javascript/pages/membership-administration-page/index.tsx b/hub/app/javascript/pages/membership-administration-page/index.tsx new file mode 100644 index 00000000..7c89279e --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.tsx @@ -0,0 +1,210 @@ +import { useState } from "react" + +import { Button, FormControl, Heading, Select, Stack, Text, TextInput } from "@primer/react-brand" + +import type { MembershipAdministrationPageProps } from "./types" +import "./styles.css" + +export function MembershipAdministrationPage({ + memberships, + invitations, + loading, + error, + onInvite, + onChange, + onRevokeInvitation, + onSignIn, + onSignOut, +}: MembershipAdministrationPageProps) { + const [provider, setProvider] = useState("github") + const [issuer, setIssuer] = useState("https://github.com") + const [subject, setSubject] = useState("") + const [role, setRole] = useState<"member" | "administrator">("administrator") + + return ( +
+
+
+ + Hub membership + + + Hub admission and installation roles are independent of repository access. + +
+
+ + +
+
+ + {error ? ( + + {error} + + ) : null} + +
+ + Invite a provider identity + +
{ + event.preventDefault() + onInvite({ provider, issuer, subject, role }) + }} + > + + Provider + setProvider(event.target.value)} + /> + + + Issuer + setIssuer(event.target.value)} + /> + + + Immutable subject + setSubject(event.target.value)} + /> + + + Role + + + +
+
+ +
+ + Memberships + +
    + {memberships.map((membership) => { + const identity = membership.user.externalIdentities[0] + return ( +
  • + + + {membership.user.displayName ?? + identity?.username ?? + identity?.subject ?? + membership.user.id} + + + {identity + ? `${identity.provider} · ${identity.issuer} · ${identity.subject}` + : "No external identity"} + + + {membership.role} · {membership.status} + + {membership.status !== "removed" ? ( +
    + + + +
    + ) : null} +
    +
  • + ) + })} +
+
+ +
+ + Invitations + +
    + {invitations.map((invitation) => ( +
  • + {invitation.subject} + + {invitation.provider} · {invitation.issuer} · {invitation.role} + + {!invitation.consumedAt && !invitation.revokedAt ? ( + + ) : ( + {invitation.consumedAt ? "Activated" : "Revoked"} + )} +
  • + ))} +
+
+
+ ) +} diff --git a/hub/app/javascript/pages/membership-administration-page/stories.tsx b/hub/app/javascript/pages/membership-administration-page/stories.tsx new file mode 100644 index 00000000..79576207 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { MembershipAdministrationPage } from "." + +const meta = { + title: "Hub/Pages/Identity/Membership administration", + component: MembershipAdministrationPage, + parameters: { layout: "fullscreen" }, + args: { + loading: false, + error: null, + memberships: [ + { + id: "membership-1", + role: "administrator", + status: "active", + user: { + id: "user-1", + displayName: "Mona Lisa", + externalIdentities: [ + { + id: "identity-1", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "mona", + }, + ], + }, + }, + { + id: "membership-2", + role: "member", + status: "suspended", + user: { + id: "user-2", + displayName: "Ada Lovelace", + externalIdentities: [ + { + id: "identity-2", + provider: "oidc", + issuer: "https://login.example", + subject: "ada-1843", + username: "ada", + }, + ], + }, + }, + ], + invitations: [ + { + id: "invitation-1", + provider: "github", + issuer: "https://github.com", + subject: "99", + role: "administrator", + expiresAt: "2026-11-03T20:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + onInvite: () => undefined, + onChange: () => undefined, + onRevokeInvitation: () => undefined, + onSignIn: () => undefined, + onSignOut: () => undefined, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/hub/app/javascript/pages/membership-administration-page/styles.css b/hub/app/javascript/pages/membership-administration-page/styles.css new file mode 100644 index 00000000..8dc46b59 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/styles.css @@ -0,0 +1,45 @@ +.membership-administration-page { + display: grid; + gap: 2rem; + max-inline-size: 72rem; + margin-inline: auto; + padding: 2rem; +} + +.membership-administration-page__header { + display: flex; + gap: 1rem; + align-items: start; + justify-content: space-between; +} + +.membership-administration-page__form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; + align-items: end; + margin-block-start: 1rem; +} + +.membership-administration-page__list { + display: grid; + gap: 0.75rem; + padding: 0; + list-style: none; +} + +.membership-administration-page__list > li { + padding: 1rem; + border: 0.0625rem solid var(--hub-border-default); + border-radius: 0.5rem; +} + +.membership-administration-page__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.membership-administration-page__error { + color: var(--hub-fg-danger); +} diff --git a/hub/app/javascript/pages/membership-administration-page/types.ts b/hub/app/javascript/pages/membership-administration-page/types.ts new file mode 100644 index 00000000..052a0139 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/types.ts @@ -0,0 +1,49 @@ +export interface MembershipIdentity { + id: string + provider: string + issuer: string + subject: string + username: string | null +} + +export interface MembershipRecord { + id: string + status: "active" | "suspended" | "removed" + role: "member" | "administrator" + user: { + id: string + displayName: string | null + externalIdentities: MembershipIdentity[] + } +} + +export interface InvitationRecord { + id: string + provider: string + issuer: string + subject: string + role: "member" | "administrator" + expiresAt: string + consumedAt: string | null + revokedAt: string | null +} + +export interface MembershipAdministrationPageProps { + memberships: MembershipRecord[] + invitations: InvitationRecord[] + loading: boolean + error: string | null + onInvite: (input: { + provider: string + issuer: string + subject: string + role: "member" | "administrator" + }) => void + onChange: ( + membershipId: string, + input: { role?: "member" | "administrator"; status?: "active" | "suspended" | "removed" }, + ) => void + onRevokeInvitation: (invitationId: string) => void + onSignIn: () => void + onSignOut: () => void +} diff --git a/hub/app/javascript/pages/sign-in-page/index.test.tsx b/hub/app/javascript/pages/sign-in-page/index.test.tsx new file mode 100644 index 00000000..a602643f --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/index.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { SignInPage } from "." + +describe("SignInPage()", () => { + it("starts sign-in with the configured provider label without presenting repository access as authentication", () => { + const onSignIn = vi.fn() + render( + undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} + />, + ) + + fireEvent.click(screen.getByRole("button", { name: "Sign in with Company SSO" })) + + expect(onSignIn).toHaveBeenCalledOnce() + expect(screen.getByText(/Sign in with Company SSO to create a Hub session/)).toBeVisible() + expect(screen.getByText(/Repository access is authorized separately/)).toBeVisible() + }) + + it("shows the provider-neutral session identity and sign-out control", () => { + render( + undefined} + onSignOut={() => undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} + />, + ) + + expect(screen.getByText(/Signed in as/)).toHaveTextContent("Mona Lisa (@mona)") + expect(screen.getByRole("button", { name: "Sign out" })).toBeEnabled() + }) + + it("disables provider-neutral sign-in when authentication is not configured", () => { + render( + undefined} + onSignOut={() => undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} + />, + ) + + expect(screen.getByRole("button", { name: "Sign in with identity provider" })).toBeDisabled() + expect(screen.getByText(/has not been configured/)).toBeVisible() + }) +}) diff --git a/hub/app/javascript/pages/sign-in-page/index.tsx b/hub/app/javascript/pages/sign-in-page/index.tsx new file mode 100644 index 00000000..47c31f3d --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/index.tsx @@ -0,0 +1,109 @@ +import { useState } from "react" + +import { Button, FormControl, Heading, Stack, Text, TextInput } from "@primer/react-brand" + +import { ReplicaLogo } from "@components/replica-logo" + +import type { SignInPageProps } from "./types" +import "./styles.css" + +export function SignInPage({ + configured, + providerLabel, + loading, + user, + error, + onSignIn, + onSignOut, + onBootstrap, + onRecovery, +}: SignInPageProps) { + const [bootstrapSecret, setBootstrapSecret] = useState("") + const [recoverySecret, setRecoverySecret] = useState("") + return ( +
+ + + + Replica Hub + + {user ? ( + <> + + Signed in as {user.displayName ?? user.username} + {user.username ? ` (@${user.username})` : ""}. + + + + ) : ( + <> + + Sign in with {providerLabel ?? "the configured identity provider"} to create a Hub + session. Repository access is authorized separately. + + + {!configured && User sign-in has not been configured for this Hub.} + {configured ? ( +
+
+ Enroll the first administrator + + Bootstrap secret + setBootstrapSecret(event.target.value)} + /> + + +
+
+ Recover administration + + Recovery secret + setRecoverySecret(event.target.value)} + /> + + +
+
+ ) : null} + + )} + {error && ( + + {error} + + )} +
+
+ ) +} diff --git a/hub/app/javascript/pages/sign-in-page/stories.tsx b/hub/app/javascript/pages/sign-in-page/stories.tsx new file mode 100644 index 00000000..b3aaf38f --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { SignInPage } from "." + +const meta = { + title: "Hub/Pages/Identity/Sign in", + component: SignInPage, + parameters: { layout: "fullscreen" }, + args: { + configured: true, + providerLabel: "Company SSO", + loading: false, + user: null, + error: null, + onSignIn: () => undefined, + onSignOut: () => undefined, + onBootstrap: () => undefined, + onRecovery: () => undefined, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const SignedOut: Story = {} +export const SignedIn: Story = { + args: { + user: { + displayName: "Mona Lisa", + username: "mona", + avatarUrl: null, + }, + }, +} +export const Unconfigured: Story = { + args: { configured: false }, +} diff --git a/hub/app/javascript/pages/sign-in-page/styles.css b/hub/app/javascript/pages/sign-in-page/styles.css new file mode 100644 index 00000000..e0632f58 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/styles.css @@ -0,0 +1,39 @@ +.sign-in-page { + display: grid; + min-block-size: 100vh; + place-items: center; + padding: 2rem; + text-align: center; +} + +.sign-in-page > * { + max-inline-size: 32rem; +} + +.sign-in-page__error { + color: var(--hub-fg-danger); +} + +.sign-in-page__enrollment { + display: grid; + gap: 0.75rem; + inline-size: min(100%, 28rem); + text-align: start; +} + +.sign-in-page__enrollment details { + display: grid; + gap: 0.75rem; + padding: 0.75rem; + border: 0.0625rem solid var(--hub-border-default); + border-radius: 0.5rem; +} + +.sign-in-page__enrollment summary { + cursor: pointer; + font-weight: 600; +} + +.sign-in-page__enrollment details[open] > * + * { + margin-block-start: 0.75rem; +} diff --git a/hub/app/javascript/pages/sign-in-page/types.ts b/hub/app/javascript/pages/sign-in-page/types.ts new file mode 100644 index 00000000..99594387 --- /dev/null +++ b/hub/app/javascript/pages/sign-in-page/types.ts @@ -0,0 +1,17 @@ +export interface SignInPageUser { + displayName: string | null + username: string | null + avatarUrl: string | null +} + +export interface SignInPageProps { + configured: boolean + providerLabel?: string | null + loading: boolean + user: SignInPageUser | null + error: string | null + onSignIn: () => void + onSignOut: () => void + onBootstrap: (secret: string) => void + onRecovery: (secret: string) => void +} diff --git a/hub/docs/README.md b/hub/docs/README.md index ec3bd8fe..36da0159 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -7,6 +7,12 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Coding conventions](coding-conventions.md): frontend layers and interface rules. - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. +- [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. +- [GitHub integration](github-integration.md): linked repository sources, reconciliation, webhooks, staleness, and permission mapping. +- [Microsoft Entra OIDC](entra-oidc.md): one-time enterprise-app setup and production OIDC configuration. +- [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. +- [Prototype authorization](prototype-authorization.md): workspaces, prototypes, immutable versions, Hub overrides, synchronized baselines, and policy precedence. +- [Artifact ingestion](artifact-ingestion.md): workload publishers, the versioned manifest, storage, immutable publishing, idempotency, and promotion. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index eb7d24c5..9e3e0c3e 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 the frontend displays the logo 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 or production OIDC authentication, Hub sessions, installation membership administration, Hub-native prototype authorization, opt-in GitHub-synchronized admission and prototype baselines, and provider-neutral artifact publishing. The frontend displays configured-provider 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 database has no product models yet. Sign-in, deployment, background jobs, and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity 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 and standards-based OIDC, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when one user sign-in provider is configured. The [prototype authorization system](prototype-authorization.md) adds workspaces, prototypes, immutable version records, direct roles, explicit deny, synchronized GitHub baselines, and one policy evaluator. The [GitHub integration](github-integration.md) stores stable repository source records and reconciles current permissions with installation credentials. The [artifact ingestion system](artifact-ingestion.md) authenticates prototype-scoped workloads, validates a shared manifest, stores bytes behind an adapter, creates immutable versions, and moves promotion pointers. Deployment and general background-job infrastructure have not been built. This app does not host previews, browse source, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -The only route is `GET /api/health`, which returns `{ "status": "ok" }`. 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` starts it 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, change membership roles or lifecycle, link repository sources, opt them into admission, inspect synchronization state, and trigger reconciliation. Product routes create and read workspaces and prototypes; read immutable versions; provision workload publishers; archive prototypes; choose Hub-managed or GitHub-synchronized prototype authorization; explain effective access; and administer direct access rules. Publishing routes ingest artifacts, read idempotent status, and move `current` or `preview`. A verified GitHub App webhook route marks affected sources dirty or removed. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication, administration, prototype authorization, GitHub synchronization, and artifact publishing runtime, starts the API at a local address, and schedules bounded periodic reconciliation. The [database guide](database.md) defines the multi-file Prisma schema and shared client in integrations. The health route remains a process check, not a database readiness check. @@ -35,7 +35,7 @@ Keep the existing `app/javascript/` layout. Application code starts React and wi Imports follow the arrows above. Shared components, hooks, and utilities must not import pages, features, or application code. Keep browser and server code separate. Agree on the API before adding types shared between them. -The frontend displays the existing logo inside the theme provider. Existing pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. +The frontend displays the configured provider sign-in and session state inside the theme provider. An active administrator sees the minimal membership and invitation page. Existing prototype pages and features use ordinary browser links. Their old destinations do not exist in the new API; keeping the interface does not make those features work. ## Other projects diff --git a/hub/docs/artifact-ingestion.md b/hub/docs/artifact-ingestion.md new file mode 100644 index 00000000..09a58d6f --- /dev/null +++ b/hub/docs/artifact-ingestion.md @@ -0,0 +1,90 @@ +# Artifact ingestion and prototype versions + +Hub accepts one provider-neutral artifact contract from workload producers. GitHub Actions, Azure DevOps, Jenkins, local scripts, Copilot workflows, and other systems use the same API. Producers build outside Hub; Hub authenticates the workload, validates and stores the bytes, creates an immutable prototype version, records provenance, and may move a channel pointer. + +## Identity and access + +A prototype administrator creates a workload publisher with `publish` and may also grant `read-status`: + +| Action | Authority | +| ------------- | ---------------------------------------------------------------------------- | +| `publish` | Publish an artifact and move the prototype's `current` or `preview` channel. | +| `read-status` | Read the result associated with that publisher's idempotency key. This action supplements `publish`; a status-only publisher would have no publication to read. | + +Hub returns a bearer credential once and stores only its SHA-256 digest. The credential is scoped to one prototype through its workload publisher. It cannot create a browser session, view a prototype through user APIs, administer access, or publish to another prototype. A producer's source URL, revision, ref, pipeline, and run ID describe where a build came from; they never grant access. + +Browser users provision credentials through `POST /api/prototypes/:prototypeId/publishers`. This requires a fresh prototype-administrator session and returns the raw token only in that response. `GET /api/prototypes/:prototypeId/publishers` returns administrator-only publisher and credential metadata without token digests or raw tokens, so another administrator can discover and revoke existing access. An administrator may temporarily disable or re-enable a publisher with `PATCH /api/prototypes/:prototypeId/publishers/:publisherId`, or permanently revoke one credential with `DELETE /api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId`. Hub audits each state change. Re-enabling a publisher does not restore a revoked credential. + +Publication, promotion, publisher state changes, and credential revocation use the same prototype lock. Publication and promotion re-read credential expiry, revocation, scope, action, token digest, and publisher state after acquiring that lock. A revocation or disable that locks first prevents an already-started workload request from committing; a workload operation that locks first completes before the administrator mutation. + +Archiving a prototype blocks new publishers, publication, publisher re-enablement, and `current` or `preview` promotion. Administrators may still disable publishers and revoke credentials after archival. Existing versions and publication status remain readable through their authorized interfaces. + +## Manifest contract + +`POST /api/publishing/prototypes/:prototypeId/versions` requires `Authorization: Bearer ...`, an `Idempotency-Key` header, and this JSON shape: + +```json +{ + "manifest": { + "schemaVersion": 1, + "prototypeId": "00000000-0000-0000-0000-000000000000", + "artifact": { + "format": "replica.prototype.bundle", + "version": "1", + "digest": "sha256:<64 lowercase hexadecimal characters>", + "sizeBytes": 1234, + "builtAt": "2026-10-27T20:00:00.000Z" + }, + "provenance": { + "provider": "jenkins", + "sourceUrl": "https://ci.example/jobs/prototype", + "revision": "abc123", + "ref": "refs/heads/main", + "pipeline": "prototype-build", + "runId": "451" + } + }, + "artifactBase64": "", + "promoteChannel": "preview" +} +``` + +The manifest and its nested objects are closed to unknown fields. Hub bounds every string, rejects NUL characters, accepts only HTTP or HTTPS source URLs without embedded usernames, passwords, query strings, or fragments, limits decoded artifacts to 8 MiB in this reference layer, verifies canonical base64, checks the decoded size, and recalculates the SHA-256 digest before storing bytes. Valid UUIDs are normalized before scope and idempotency checks, so hexadecimal casing does not change identity. The manifest prototype ID must match the route. + +Publishing requests are rate-limited before JSON parsing. The version-upload route alone accepts the bounded upload-sized JSON body; status and promotion retain the API's default JSON limit. + +## Storage boundary + +`ArtifactStorage` separates ingestion from byte storage. The reference adapter writes content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`, defaulting to `~/.replica/artifacts` outside the source checkout and Vite development root. Database rows store only the storage key and validated metadata, never the artifact bytes. + +The local adapter is suitable for one Hub process with a durable mounted filesystem. It rejects relative configured paths and symbolic-link roots, shards, and artifact paths, normalizes root spellings before validation, resolves directories under the configured root, opens artifact files without following a final symbolic link, and syncs file and directory entries through the storage-root boundary before reporting a successful write. Operators must keep the storage directory writable only by the Hub workload; the local adapter does not provide replication, remote durability, encryption-key management, hostile concurrent filesystem isolation, or multi-region consistency. A production object-store adapter must preserve the same content-addressed, immutable `put`, `read`, and `remove` boundary. + +Storage happens before the database transaction. A failed transaction can therefore leave an unreferenced content-addressed file. Hub never deletes referenced bytes automatically. Operators may remove only files that have no matching `PrototypeVersion.storageKey`; retain unreferenced files long enough to exclude in-flight transactions and backups. Version metadata has no deletion API and must not be rewritten silently. + +## Idempotency and immutability + +Hub hashes the idempotency key and stores it with a digest of the validated publication request. Retrying the same request with the same publisher and key returns the existing version. Reusing the key for different manifest or promotion input returns `idempotency_conflict`. Publishing the same content digest again to the prototype returns `artifact_already_published`. + +Publishing locks the prototype while it assigns the next sequence. Concurrent requests therefore create one ordered history. PostgreSQL rejects every update or deletion of a prototype-version row, including accidental writes outside the application. + +## Promotion and rollback + +`current` and `preview` are mutable pointers to immutable versions. A publish request may move one pointer in the same transaction that creates the version. `PUT /api/publishing/prototypes/:prototypeId/channels/:channel` moves a pointer to any version owned by that prototype. Rollback uses that endpoint to point the channel at an earlier version; it never mutates the version. + +## Status, audits, and failures + +`GET /api/publishing/prototypes/:prototypeId/status` reads the publication associated with the caller's `Idempotency-Key`. It requires `read-status`. Publishing responses are private and non-cacheable because the idempotency key is carried in a request header rather than the URL. + +Version creation, idempotency ownership, optional promotion, and the `prototype.version.publish` security audit commit in one transaction. Promotion writes `prototype.channel.promote` in its transaction. Audit failure rolls back database state. Responses expose validated version metadata and stable error codes, not credentials, storage paths, or artifact contents. Publishing routes omit headers and request payloads from structured request logs. + +## Local producer + +The local CLI proves that publishing does not depend on GitHub: + +```sh +cd hub +REPLICA_PUBLISH_TOKEN=... npm run publish:artifact -- \ + http://127.0.0.1:3001 PROTOTYPE_ID ./prototype.tar preview +``` + +The CLI prints its generated or configured idempotency key before sending the request. If the response is lost, retry with that value in `REPLICA_IDEMPOTENCY_KEY` to recover the committed result. Optional `REPLICA_PROVENANCE_PROVIDER`, `REPLICA_SOURCE_URL`, `REPLICA_REVISION`, `REPLICA_REF`, `REPLICA_PIPELINE`, and `REPLICA_RUN_ID` values populate provenance without changing authorization. diff --git a/hub/docs/backend-architecture.md b/hub/docs/backend-architecture.md index 60976740..4a410ae1 100644 --- a/hub/docs/backend-architecture.md +++ b/hub/docs/backend-architecture.md @@ -37,6 +37,16 @@ Export route handlers by HTTP method, such as `get()`, and alias them to descrip Add directories when their functions are needed. Do not add empty implementations just to fill out the structure. +Keep function implementations flat. A composition factory may bind imported functions to dependencies, but must not contain their implementations. Put schemas and constants in a colocated `constants.ts`, and types in `types.ts`. + +Keep domain contracts with their domains, GitHub types and errors with that integration, and database inputs with their repositories. Keep only general helpers in utilities. + ## Database access The [shared Prisma client](database.md#shared-client) lives in `server/integrations/prisma/index.ts`. Domains may pass it to repositories for database queries without letting repositories import integrations. Keep model definitions, naming, and migrations consistent with the [database conventions](database.md#schema-conventions). + +Compose repository functions in domains. The identity store binds standalone queries to a database client or transaction there; a repository must not import its neighboring queries to build that store. + +`staticIdentityStoreRecordId` identifies the row used to serialize identity-store transactions. It is not a user, provider configuration, or GitHub installation. Keep it with the identity-store state repository. + +GitHub source-integration credentials use a separate fixed state row. Authentication-provider configuration must not read or write that credential state. diff --git a/hub/docs/coding-conventions.md b/hub/docs/coding-conventions.md index 31360d5d..cd990769 100644 --- a/hub/docs/coding-conventions.md +++ b/hub/docs/coding-conventions.md @@ -9,8 +9,12 @@ Follow the [database conventions](database.md#schema-conventions) for Prisma mod - Use TypeScript for new application code and tests. - Put each standalone function or component in a kebab-case directory with `index.ts` or `index.tsx`, colocated tests, types, and styles. - Export at most one function per file and import exact directories rather than sibling-export barrels. +- Keep helpers in their own function directories. Put schemas and constants in `constants.ts` and types in `types.ts`. - Group package, alias, and relative imports separately. -- Omit `/index.ts` and `/index.tsx` from imports in frontend code and Vitest tests. Keep explicit `.ts` file paths in server runtime and Node-loaded tooling, including Storybook configuration. Native Node does not resolve directory imports; the server compiler rewrites its paths for production. +- Use `@/` for browser code and `#server/` for API code. Import function directories without `/index.ts`, such as `#server/utils/digest-secret`. Import colocated constants and types from their named files. +- [Package imports](../package.json) resolve `#server/` to source with Node's `development` condition and to compiled JavaScript otherwise. `npm run dev:api` selects that condition; `npm start` uses compiled files. TypeScript and Vitest resolve source through the same mappings. Native Node package aliases must start with `#`; `@/` remains browser-only. +- Keep explicit `.ts` paths for relative imports in native Node modules and tooling, including Storybook configuration. The server compiler rewrites relative extensions for the build. +- Prefer named alias imports such as `#server/integrations/github/identify-user/constants` over relative paths when a mapping exists. Vite resolves extensionless relative paths, but native Node does not. TypeScript `paths` alone cannot make `@/server/...` work in Node or rewrite it in emitted JavaScript. - Keep HTTP parsing and responses outside domains. Validate input and return explicit errors when introducing real API behavior. ## Frontend layers diff --git a/hub/docs/database.md b/hub/docs/database.md index 6cb52918..73c8441b 100644 --- a/hub/docs/database.md +++ b/hub/docs/database.md @@ -1,6 +1,6 @@ # Hub database -Hub uses PostgreSQL and Prisma. Product models and migrations have not been added. +Hub uses PostgreSQL and Prisma. The schema stores the provider-neutral [identity foundation](identity.md), the [prototype authorization](prototype-authorization.md) product core, and [artifact publication metadata](artifact-ingestion.md). [Compose](../.devcontainer/compose.yaml) pins the development database image. [package.json](../package.json) pins the Prisma packages to the same version. Dependency updates must follow the [seven-day dependency policy](../../docs/testing-and-review.md#update-dependencies). @@ -46,6 +46,22 @@ 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 manual rule per scope. The database requires an allowed rule to have a role and an explicit deny to have no role. Prototype versions have a unique sequence within their owning prototype and use restrictive deletion so history cannot be detached or silently removed. + +GitHub synchronization uses separate records: + +- `GitHubRepositorySource` identifies a repository by immutable GitHub ID and the App and installation configuration that confirmed it. Mutable owner, name, default branch, active/removed state, dirty state, failures, synchronization generation, and timestamps do not define identity. The generation is a compare-and-set boundary for rejecting stale in-flight reconciliation results. +- `GitHubAdmissionAttempt` stores a per-repository, per-GitHub-subject generation. Live sign-ins use it to order one user's permission confirmations without invalidating reconciliation or another user's sign-in. +- `GitHubSynchronizedAccess` stores one user's raw permission, mapped prototype role, admission eligibility, source, status, and last confirmation for one repository source. +- `GitHubAuthorizationPolicy` stores the bounded last-known-good duration. The initial policy is one day and may not exceed seven days. +- `GitHubWebhookDelivery` deduplicates verified deliveries by GitHub delivery ID. + +`Prototype.authorizationMode` defaults to `hub-managed`. `sourceRepositoryId` is nullable and applies only when the mode is explicitly `github-synchronized`. Synchronized records never share rows with workspace or prototype grants, so reconciliation cannot overwrite manual Hub decisions. + +A database trigger rejects prototype version updates and deletions so history cannot be rewritten or silently removed. Workload credentials store token digests plus optional expiry and revocation timestamps; publishers have an independent active flag for temporary suspension. Publication rows bind a publisher-scoped idempotency-key digest to one version, and channel rows keep mutable pointers separate from immutable version metadata. Artifact bytes stay outside relational columns behind the storage boundary. + +Installation membership `source` distinguishes linked-source synchronization from the `legacy-synchronized` compatibility policy. Any administrator lifecycle change or assignment of Hub administration converts a synchronized membership to `manual`, so reconciliation cannot undo the Hub override or orphan administration. + ## Shared client Import `prisma` from `server/integrations/prisma/index.ts` rather than constructing clients in each caller. The integration uses Prisma's [documented global cache pattern](https://www.prisma.io/docs/orm/v7/more/troubleshooting/nextjs#avoiding-multiple-prisma-client-instances) to reuse one client across development module reloads. Production uses the module instance without setting the global variable. diff --git a/hub/docs/development.md b/hub/docs/development.md index b91ecf9f..dedc7e28 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -1,6 +1,6 @@ # Hub development -Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. Sign-in and production deployment are not configured. +Use the Hub devcontainer for Node.js and PostgreSQL, or run Node.js locally with the same database service. User sign-in is optional in local development. Production deployment is not configured. ## Devcontainer @@ -20,7 +20,7 @@ cp .env.example .env npm ci --engine-strict ``` -The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. The existing health and frontend routes do not query the database. +The example URL connects to PostgreSQL on `127.0.0.1:5432`. To use another development database, set `DATABASE_URL` in `.env` instead. Do not commit credentials. The devcontainer loads its committed `github` provider default first and then the optional `hub/.env`, so the local file can select OIDC without a Compose edit. Set `HUB_AUTH_PROVIDER=github` with `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in, or select and configure the complete OIDC profile in [Microsoft Entra OIDC](entra-oidc.md). Keep `HUB_AUTH_PROVIDER=github` without GitHub credentials to run Hub with sign-in disabled. Set separate `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values to exercise first-administrator enrollment and no-administrator recovery. ## Start the app @@ -36,10 +36,12 @@ In a second terminal, also from `hub/`, start Vite: npm run dev ``` -Open . Vite serves the frontend and forwards `/api` requests to Express at . `GET /api/health` returns JSON; other API routes have not been built. Both servers accept connections only from this machine. Stop each with Ctrl+C. +Open . Vite serves the sign-in surface and forwards `/api` requests to Express at . `GET /api/health` returns JSON. `GET /api/session` reports the browser session and supplies the CSRF value used by the sign-in and sign-out controls. Both servers accept connections only from this machine. Stop each with Ctrl+C. The API reads an optional `PORT` environment variable, defaulting to 3001. If you change it, update the local Vite proxy target to match. The editor forwards ports 3001, 5173, and 6006 from the devcontainer. API startup reads `.env` when present; Compose's environment takes precedence. +Hub ignores forwarded client-address headers by default and rate-limits the direct connection address. Keep `HUB_TRUSTED_PROXY_HOPS=0` for local development and any deployment where clients can reach the API directly. A deployment whose API is reachable only through a fixed proxy chain may set the exact number of trusted hops. Every trusted proxy must replace or append the client address correctly, and the network must prevent clients from bypassing the first trusted proxy. Never enable proxy trust only to accept arbitrary `Forwarded` or `X-Forwarded-For` headers. + ## Database lifecycle PostgreSQL stores data in a named Compose volume. Stopping or rebuilding the devcontainer preserves it. For a local Node.js setup, stop the database with `docker compose -f .devcontainer/compose.yaml down`; this also preserves data. Do not add `--volumes` unless you intend to permanently delete the local database. diff --git a/hub/docs/entra-oidc.md b/hub/docs/entra-oidc.md new file mode 100644 index 00000000..f8a7526e --- /dev/null +++ b/hub/docs/entra-oidc.md @@ -0,0 +1,64 @@ +# Microsoft Entra OIDC + +Hub supports one user sign-in provider per installation. The first production OIDC profile is a single-tenant Microsoft Entra ID enterprise application, but Hub uses OpenID Connect discovery, authorization code, PKCE, nonce, and signed ID-token validation rather than an Entra authentication SDK. + +## Separate setup from sign-in + +A tenant administrator performs the one-time application setup. Ordinary users then sign in through the configured enterprise application. Tenant consent, application ownership, client-credential storage, and group assignment remain operator responsibilities; Hub does not bypass those approvals. + +Run the read-only preflight without Microsoft Graph permissions: + +```powershell +./script/setup-entra-oidc.ps1 -Preflight -HubOrigin https://hub.example.com +``` + +Install the listed Microsoft Graph PowerShell modules, sign in with the required tenant permissions, and inspect the intended changes: + +```powershell +./script/setup-entra-oidc.ps1 -Plan ` + -HubOrigin https://hub.example.com ` + -TenantId ` + -UserGroupId ` + -AdministratorGroupId +``` + +Use `-Apply` with the same arguments to converge the application, service principal, assignment requirement, callback, required `auth_time` ID-token claim, `Hub.User` and `Hub.Administrator` app roles, and group assignments. Apply adds missing requested groups and removes obsolete group assignments for those two managed roles; unrelated optional claims, app roles, and assignments are preserved. Use `-ExistingApplicationId ` to converge an existing application without claiming removal ownership. `-Remove` deletes only resources tagged as created by this script and refuses unowned resources. + +The script prints nonsecret Hub configuration. It does not create or print a client credential. Create the credential through the tenant's approved process, place it in the secret store exposed by `HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE`, and protect it separately from the database. + +## Hub configuration + +Set the complete OIDC profile. Hub does not fall back to GitHub when OIDC is selected or unavailable. + +```dotenv +HUB_AUTH_PROVIDER=oidc +HUB_OIDC_ENABLED=true +HUB_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 +HUB_OIDC_CLIENT_ID= +HUB_OIDC_CALLBACK_URL=https://hub.example.com/api/auth/oidc/callback +HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE=HUB_OIDC_CLIENT_SECRET +HUB_OIDC_CLIENT_SECRET= +HUB_OIDC_MEMBER_ROLE=Hub.User +HUB_OIDC_ADMINISTRATOR_ROLE=Hub.Administrator +HUB_OIDC_SCOPES=openid profile email +HUB_OIDC_MEMBERSHIP_POLICY=jit-assignment +HUB_OIDC_PROVIDER_REVISION=1 +``` + +The callback must exactly match `HUB_ORIGIN` plus `/api/auth/oidc/callback`. The first supported profile requires the issuer `https://login.microsoftonline.com//v2.0`; Hub does not accept a generic hostname or tenant alias. Discovered authorization, token, and JWKS endpoints must use that same HTTPS origin. Increase `HUB_OIDC_PROVIDER_REVISION` whenever any OIDC setting or referenced credential changes. Hub rejects revision rollback and same-revision configuration drift. + +Prefer rotating the client credential without replacing the Entra application client ID. If an operator intentionally replaces the application in the same tenant, Entra changes the application-specific `sub`; Hub uses the signed tenant `oid` to migrate the existing external identity to the new subject and records that migration. This continuity does not link providers or tenants. + +`jit-assignment` creates an `oidc-assignment` membership for a newly seen identity that has `Hub.User` or `Hub.Administrator`. Those provider-owned memberships continue to track the current mapped app role. `invitation-required` also requires a matching Hub invitation. For Entra invitations, enter the user's tenant directory object ID (`oid`), which an administrator can copy from the user's **Object ID** in the Entra admin center or read through approved Microsoft Graph directory tooling. Hub keeps the application-specific OIDC `sub` as the external sign-in identity and uses the tenant-scoped `oid` only to match the pre-created invitation. An administrator invitation is not consumed until the provider also grants `Hub.Administrator`; Hub never silently activates it as a member. Invitation, bootstrap, recovery, and manually changed memberships preserve their Hub-owned role instead of being overwritten by later app-role claims. Both policies preserve suspended membership. A removed membership returns only through a new matching invitation; an app-role claim alone does not silently reactivate it. Hub refuses a claim-driven demotion of an `oidc-assignment` membership that would remove the final active administrator. + +## Identity and lifecycle boundaries + +OIDC proves the immutable tuple `provider + issuer/tenant + subject`. Preferred username, email, name, and picture are mutable display attributes and are never identity or authorization keys. The signed Entra `oid` claim is a separate tenant-scoped invitation assignment key. Hub discards the authorization code, ID token, access token, and any refresh token after extracting and validating identity, directory assignment, and role claims. + +JIT provisioning occurs only during a successful sign-in and creates or refreshes Hub membership under the configured admission policy. It is not SCIM: Hub does not synchronize users, groups, suspension, or removal in the background. Entra assignment and app roles control installation admission and administrator mapping; they do not grant future prototype, repository, workspace, artifact, or review permissions. + +Keep at least one group assigned to `Hub.Administrator`. Administrator recovery still requires both the host-held recovery secret and a signed `Hub.Administrator` app-role claim; it does not bypass a missing tenant assignment. + +Fresh administrator verification requires Entra's `auth_time` optional ID-token claim. The setup script configures it. For an application configured manually in the portal, add the `auth_time` optional claim to ID tokens before relying on administrator mutations. + +Hub sign-out revokes the opaque Hub session. It does not assume or initiate Entra single sign-out. Provider logout can be added later as a separately configured deployment behavior. diff --git a/hub/docs/github-integration.md b/hub/docs/github-integration.md new file mode 100644 index 00000000..686360b8 --- /dev/null +++ b/hub/docs/github-integration.md @@ -0,0 +1,56 @@ +# GitHub integration + +GitHub-synchronized authorization uses repository permissions as an opt-in baseline below Hub overrides. It is not repository-gated authorization: Hub links the source, chooses where synchronization applies, maps the permission, enforces staleness, and makes the final admission and prototype decisions. + +## Source identity and credentials + +A linked source stores GitHub's immutable repository ID, the configured App and installation IDs, the current owner, name, default branch, visibility, active, suspended, or removed state, admission switch, synchronization status, and timestamps. Admission resolves current metadata from that immutable ID before checking a user permission, so a renamed repository cannot be confused with a replacement at its old name. Public and internal sources additionally require GitHub to confirm that the user is an actual collaborator before their permission can supply admission or a prototype baseline. Rename changes mutable fields without replacing the source. Prototype source provenance is separate from build, artifact, manifest, and ingestion records. + +All permission checks use an installation token created from the configured GitHub source-integration credentials. GitHub user authorization proves identity only. Hub does not retain or reuse the user's transient token for synchronization. + +## Opt-in policies + +`HUB_ADMISSION_POLICY=hub-managed` is the default. Select `github-synchronized` to permit linked sources to participate, then enable admission on each intended repository through the administration API. No linked or opted-in source means no synchronized admission. + +Each prototype separately selects `hub-managed` or `github-synchronized`. The synchronized mode also requires one active repository source. Unlinking a source removes its synchronized baselines and leaves the prototype without a usable baseline until an administrator selects another source or returns it to Hub-managed mode. + +`legacy-repository` is a compatibility policy for current Replica behavior. It scans installation repositories during sign-in and is intentionally not the default or the normal synchronized source model. + +## Permission mapping + +| Raw GitHub permission | Admission | Prototype baseline | +| --- | --- | --- | +| `admin`, `maintain`, `write` | Member | `editor` | +| `triage` | Member | `commenter` | +| `read` | Member | `viewer` | +| `none` or missing | Denied | None | + +GitHub does not map to Hub installation administrator or prototype administrator. GitHub's built-in `role_name` takes precedence over its legacy base `permission` field, so `role_name: triage` maps to commenter even when the base permission is `read`; unknown custom roles fall back to the base permission. Explicit Hub deny wins first, followed by explicit prototype and workspace roles, then the synchronized baseline. + +## Reconciliation and webhooks + +`POST /api/administration/github/repositories/:repositorySourceId/reconcile` confirms repository metadata and every known GitHub identity's current collaborator permission. The API process also reconciles active sources every 15 minutes. Reconciliation repairs missed webhooks and permission changes for collaborators or teams because GitHub does not emit a complete permission-change stream. + +Each reconciliation claims a monotonically increasing source generation while holding the repository row lock. Reconciliation and live admission permission requests also share a separate generation per repository and immutable GitHub subject, so whichever request starts later supersedes older permission results for only that user without invalidating the repository scan or another user's sign-in. These per-user claims use the repository row lock without taking the global identity-store lock. Admission claims before resolving mutable repository metadata and returns a retryable conflict when the source generation changed, so a rename or transfer cannot substitute a replacement repository or appear as permission loss. + +`POST /api/github/webhooks` accepts only HMAC-SHA256 verified GitHub App payloads. An irrelevant signed event such as GitHub's initial `ping` is ignored before installation-specific validation. Revocation events match the signed installation ID against trusted linked-source records and do not require usable API credentials, so credential loss cannot block deletion or suspension. Relevant installation, installation-repository, repository, member, membership, and team-add events mark affected sources dirty. Dirty events are coalesced into a bounded near-term retry so a scan superseded by frequent organization changes does not wait for the next 15-minute tick. Repository deletion or installation removal processes both active and suspended links, removes all affected sources first, and performs one membership-enforcement pass. + +## Staleness and outages + +A successful confirmation records its own `lastConfirmedAt`, including a confirmed negative permission that invalidates an older positive cache. A cache fallback is revalidated under the callback transaction before it can reactivate membership or create a session. Installation-token 404 removes matching sources rather than using cache. Credential and configuration failures record an explicit failed status without refreshing confirmation. Hub permits a last-known-good baseline for one day, then fails closed. Source credential failures never block an already active manual Hub member or a usable Hub invitation, but synchronized members remain fail-closed. + +Scheduled reconciliation has a single-flight guard. A slow periodic run completes before another scheduled run starts; manual reconciliation still uses source generations to supersede older results safely. Manual reconciliation checks fresh verification before the provider scan, then rechecks only the active administrator session when applying confirmed results, so a long scan does not discard valid results solely because the five-minute freshness window elapsed. Scans include only identities with active Hub membership, issue permission requests with bounded concurrency, and apply confirmations in bounded transactions outside the global identity lock. One user's provider failure leaves that user's timestamp unchanged while other confirmed results apply; the source records a bounded partial-failure status and scheduled runs emit one static operational error record with the source ID and bounded reason. + +Manual Hub grants, denies, membership assignments, and suspensions are separate rows. Synchronization never deletes or rewrites them. Repository unlink, installation removal, and permission loss affect synchronized rows only. + +## Administration API + +- `GET /api/administration/github/repositories` +- `POST /api/administration/github/repositories` +- `PATCH /api/administration/github/repositories/:repositorySourceId` +- `DELETE /api/administration/github/repositories/:repositorySourceId` +- `POST /api/administration/github/repositories/:repositorySourceId/reconcile` +- `PUT /api/prototypes/:prototypeId/authorization` +- `GET /api/prototypes/:prototypeId/access/explanation` + +Administration mutations require an active installation administrator, fresh verification, the configured origin, and matching CSRF cookie and header. Repository listing requires the active administrator role but not a fresh verification because it is read-only. Product policy changes require a fresh effective prototype administrator. Request bodies are omitted from logs, responses are private and non-cacheable, and mutations write structured audits in the same transaction. diff --git a/hub/docs/identity.md b/hub/docs/identity.md new file mode 100644 index 00000000..27d1cc15 --- /dev/null +++ b/hub/docs/identity.md @@ -0,0 +1,71 @@ +# Hub identity + +Hub uses one configured user sign-in provider to prove an external identity. Hub then applies its installation admission and membership rules before creating a provider-neutral browser session. Entra app-role assignments do not grant prototype access. Repository permissions affect installation admission only through the explicitly selected GitHub synchronization policy, and affect prototype access only when a prototype separately opts into a synchronized repository baseline. + +## Production OIDC + +Hub supports standards-based OIDC authorization code flow as a sibling of GitHub user authorization. The first deployment profile is single-tenant Microsoft Entra ID with `Hub.User` and `Hub.Administrator` app roles. See [Microsoft Entra OIDC](entra-oidc.md) for the setup script, exact configuration, assignment policy, and operational boundary. + +OIDC discovery must return the exact configured issuer. Hub binds state to the initiating browser, uses PKCE and nonce, consumes attempts once, exchanges the code with a bounded timeout, verifies the ID-token signature through cached rotating JWKS, and validates issuer, audience, authorized party, token lifetime, and nonce. The configured issuer is host configuration, never request input. Hub rejects non-public or cross-origin discovery endpoints. OIDC callbacks accept one query-string authorization code up to 8,192 characters and one exact 43-character base64url state. GitHub keeps its 1,000-character code limit. Duplicate, missing, or oversized values fail before exchange, with only a stable nonsecret failure reason returned to the browser. Hub requests `response_mode=query`; `form_post` is not enabled. + +Hub maps `oidc + issuer/tenant + subject` to `ExternalIdentity`. For Entra, Hub also stores the signed tenant directory object ID (`oid`) as a separate assignment identifier so an administrator can create an invitation before the user's first sign-in. Login, email, name, and picture are mutable display attributes. They never admit a user or select an existing identity. The authorization response and provider tokens are discarded after identity and app-role extraction. + +## GitHub user authentication + +Set `HUB_AUTH_PROVIDER=github`, `HUB_ORIGIN`, `HUB_GITHUB_AUTH_CLIENT_ID`, and `HUB_GITHUB_AUTH_CLIENT_SECRET` to enable GitHub sign-in. `HUB_AUTH_PROVIDER` is always required; Hub does not infer a provider from whichever credentials happen to be present. `HUB_ORIGIN` must be an HTTPS origin, except that local development may use HTTP on loopback. The callback is always `${HUB_ORIGIN}/api/auth/github/callback`. + +Hub stores a versioned authentication-provider revision with non-secret configuration and a fingerprint that changes when the client secret changes. The client secret remains in the process environment. Each authorization attempt stores digested state and browser bindings, a PKCE verifier, the provider revision, the current session-revocation generation, its purpose, and a ten-minute expiry. + +The callback consumes an attempt once, checks the same-browser binding and current provider and revocation state, exchanges the code with PKCE, and reads the authenticated GitHub profile. Hub maps GitHub's immutable numeric account ID to `ExternalIdentity.subject`. The user access token exists only while requesting that profile; Hub never stores it. + +Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. Restoring a session for a `synchronized` membership also requires at least one fresh admission-eligible linked baseline, so expired access fails closed immediately rather than waiting for the periodic sweep. + +`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. + +`github-synchronized` is opt-in. It checks only repository source records whose admission switch is enabled, resolves their current metadata by immutable GitHub repository ID, uses the separately configured GitHub App installation credentials, and records the confirmed raw permission and mapped baseline separately from the membership. A qualifying baseline creates or refreshes only a `synchronized` member assignment. Manual, invitation, bootstrap, recovery, suspended, and administratively removed assignments remain Hub overrides and are never rewritten by synchronization. Permission loss removes a synchronized membership and its sessions only after reconciliation or the bounded last-known-good window expires. + +`legacy-repository` preserves the previous compatibility behavior. It scans repositories available to the installation and creates a `legacy-synchronized` member assignment after finding any repository permission, without requiring linked source records. The migration backfills pre-existing synchronized compatibility memberships to that source before linked-source synchronization begins. Linked-source reconciliation does not manage those compatibility assignments. It is not the normal synchronized authorization mode. The GitHub authentication adapter itself never reads installation credentials or performs repository authorization. + +Artifact publishing uses a separate workload identity boundary. Prototype administrators issue prototype-scoped credentials with `publish` and/or `read-status`; Hub returns the raw bearer token once and stores only its digest. Workload credentials never create browser sessions or inherit user, installation, repository, or source-provider access. See [Artifact ingestion](artifact-ingestion.md). + +Sign-out revokes the current database session. Provider logout is optional and is not assumed. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh OIDC verification requests `prompt=login` and `max_age=0`, requires an `auth_time` no older than the browser-bound authorization attempt, and must return the same external identity before administrator mutations. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. + +## Persisted boundaries + +- `User` is the Hub-owned account. It does not contain a provider identifier or administrator flag. +- `ExternalIdentity` links a provider, issuer, and subject to one Hub user. One user may link several identities. The issuer is part of uniqueness so separate OIDC tenants may reuse subjects safely. +- `InstallationMembership` stores a user's `member` or `administrator` role and `active`, `suspended`, or terminal `removed` status for this Hub installation. +- `MembershipInvitation` targets a provider assignment by provider, issuer, and immutable assignment identifier. GitHub uses its account subject; Entra uses the tenant directory object ID (`oid`) so administrators can create an invitation before first sign-in. Activation consumes the invitation once and creates or reactivates the linked user's membership. +- `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 and product context. Audit records describe changes but do not grant access. + +Store operations can share one database transaction and lock a fixed store-state row. Related membership, invitation, session, and audit changes therefore commit or roll back together. Overlapping final-administrator changes and invitation activations observe committed state. + +## Installation admission and administration + +Set separate host-held `HUB_BOOTSTRAP_SECRET` and `HUB_ADMIN_RECOVERY_SECRET` values. Bootstrap can enroll the first administrator once. Its durable completion marker remains after expired authorization attempts are cleaned up. Recovery is available only when no active administrator remains and never resets bootstrap. + +Administrators invite an identity by provider, issuer, and immutable assignment identifier. Mutable usernames, display names, and email addresses do not activate invitations. Invitations expire after seven days, can be revoked before use, and are consumed by one matching authentication callback. Removed memberships require a new invitation. Suspended memberships must be restored by an administrator. + +Administrator mutations require an active administrator session verified less than five minutes ago. Hub prevents concurrent demotion, suspension, or removal from eliminating the final active administrator. See [Installation membership and administration](membership-and-administration.md). + +## GitHub source-integration credentials + +GitHub source access is an optional integration boundary, not the global identity boundary. Set `HUB_GITHUB_CREDENTIAL_PROVIDER` in the [environment example](../.env.example): + +| Provider | Credentials | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `database` (default) | Generate `HUB_GITHUB_CREDENTIAL_KEY` with `openssl rand -hex 32`. The provider encrypts GitHub integration credentials before storage and authenticates their context when reading. | +| `environment` | Set every `GITHUB_*` value, including `GITHUB_INSTALLATION_ID`. This provider is read-only and ignores database credentials. | + +Both providers currently validate an App ID, installation ID, client ID, client secret, and RSA private key. IDs are positive decimal strings. The key must contain at least 2048 bits. A deployment may use the same GitHub App for user authorization and source integration, but it supplies and rotates the two configurations independently. Set `HUB_GITHUB_WEBHOOK_SECRET` separately when enabling the webhook route. + +Database credentials use AES-256-GCM with contextual additional authenticated data. Keep the encryption key outside the database and back it up separately. Losing or replacing it makes saved credentials unreadable. Invalid provider settings or unreadable ciphertext cause errors; Hub never falls back to another provider. + +## Current scope + +This layer adds explicit GitHub or OIDC sign-in, Hub-managed and opt-in GitHub-synchronized installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. The separate [prototype authorization](prototype-authorization.md) boundary may consume a synchronized repository baseline without treating installation membership as product authority. Artifact ingestion composes with identity only when a browser administrator provisions a workload credential; publication itself does not use a browser session. Identity does not add a multi-provider chooser, cross-provider account linking, SCIM or Graph lifecycle synchronization, or background Graph access. + +Apply the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/logging.md b/hub/docs/logging.md index a32eb149..0a8bd4ec 100644 --- a/hub/docs/logging.md +++ b/hub/docs/logging.md @@ -30,18 +30,22 @@ Each request produces one final record with `reqId`, `method`, `route`, `statusC Automatic records exclude raw URLs, headers, cookies, uploads, response bodies, and error details. JSON parsing uses Express's default 100 KiB limit; rejected bodies are not logged. The Express error handler returns generic JSON errors, or closes the connection if headers were sent, without exposing raw errors. -For private free text or other sensitive payloads, exclude all payload fields before sending a response: +`createApp()` installs `omitRequestLogPayload` for authentication, session, administration, product, and publishing paths after request logging but before JSON parsing. Publishing responses receive private, non-cacheable headers before rate limits and parsing; publishing rate limits also run before parsing, and only the artifact-upload route receives the larger bounded parser. This ordering covers parser failures as well as later origin checks and handlers: ```ts -response.locals.omitLogPayload = true +import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" + +app.use(["/api/auth", "/api/session"], omitRequestLogPayload) ``` This omits `params`, `query`, and `body` while keeping request metadata. +OAuth codes, state values, CSRF values, cookies, PKCE material, provider responses, workload credentials, artifact manifests, and artifact bytes therefore never enter automatic request payload records, including when JSON is malformed or too large. Automatic records never include request headers, so bearer credentials are omitted as well. + ## Structured-field safety Application fields, `request.log` fields, and automatic payloads share the same sanitizer. It copies data without changing the request or invoking getters or custom serialization methods. -Keys containing password, passwd, passphrase, secret, token, authorization, cookie, API key, private key, or session ID become `[REDACTED]`. Matching ignores case and punctuation and includes nested objects and arrays. Exclude payloads whose sensitive fields do not match these rules. +Keys containing password, passwd, passphrase, secret, token, authorization, cookie, API key, private key, or session ID become `[REDACTED]`. Automatic request payloads also redact OAuth and OIDC `code`, `state`, `nonce`, and `code_verifier` fields. Matching ignores case and punctuation and includes nested objects and arrays. Exclude payloads whose sensitive fields do not match these rules. Structured fields have [size, depth, and entry limits](../server/utils/logging/sanitize-log-value/constants.ts). Markers replace truncated data, cycles, getters, and unsupported values. These limits do not apply to messages or custom bindings and do not replace endpoint validation or privacy decisions. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md new file mode 100644 index 00000000..0d9007d8 --- /dev/null +++ b/hub/docs/membership-and-administration.md @@ -0,0 +1,69 @@ +# Installation membership and administration + +Hub owns installation admission after an authentication provider proves an identity. Repository access affects installation membership only through the explicit GitHub-synchronized policy; Hub remains the final evaluator. + +For OIDC, a configured member or administrator app role is required before Hub evaluates membership. Under `jit-assignment`, a first successful sign-in may create an `oidc-assignment` membership from that assignment; only this provider-owned source tracks later app-role changes. Under `invitation-required`, an Entra administrator creates the invitation with the tenant issuer and the user's directory Object ID (`oid`) from the Entra admin center or approved Graph tooling. Hub matches that signed tenant-scoped claim while retaining the application-specific OIDC `sub` as the external identity. Invitation, bootstrap, recovery, and manually changed membership roles remain Hub-owned. Suspended membership remains suspended. Removed membership requires a new invitation and is never silently reactivated from a provider claim. + +## Membership contract + +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. 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. Manual removals require a new invitation; synchronized removals can return only after a new qualifying confirmation. | + +Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. + +Synchronization creates and refreshes only memberships whose source is `synchronized`. It never reactivates or rewrites a manual, invitation, bootstrap, recovery, suspended, or administratively removed membership. Administrator membership changes convert a synchronized assignment to `manual`. Assigning installation, workspace, or prototype administrator authority also makes the membership manual, because GitHub never owns Hub administration. When no fresh admission-eligible baseline remains, reconciliation marks only an ordinary synchronized member removed and deletes its sessions in the same serialized transaction. The compatibility policy uses `legacy-synchronized`, which linked-source reconciliation does not manage. + +Suspension and removal also check Hub-managed product administration in the same transaction. The change fails with `product_administration_orphaned` until another active user can administer every affected workspace and prototype. A role change from installation administrator to member does not remove explicit workspace or prototype roles. + +## Invitations + +An invitation targets an external provider assignment: + +```text +provider + issuer + immutable assignment identifier -> ExternalIdentity -> User -> InstallationMembership +``` + +An administrator may invite a member or administrator. GitHub invitations use the immutable account subject. Entra invitations use the tenant directory Object ID (`oid`) so the value is available before the application-specific OIDC `sub` is first observed. Hub never matches mutable login, display-name, or email fields. The invitation expires after seven days, can be revoked before activation, and can be consumed once. A matching authentication callback activates the membership and creates the session in one serialized operation. + +Hub rejects invitations for active or suspended assignments. A removed assignment may receive a new invitation. + +## Bootstrap and recovery + +Set two independent host-held secrets: + +- `HUB_BOOTSTRAP_SECRET` authorizes the first administrator enrollment once. +- `HUB_ADMIN_RECOVERY_SECRET` authorizes recovery only when no active administrator remains. + +Both routes verify the secret before starting a browser-bound, one-use provider authorization. The callback must complete in that browser. Hub stores neither secret in the database. Bootstrap has a durable completion marker and cannot reopen after attempt cleanup. Recovery does not change that marker. + +Keep both values outside Git and transfer them through the deployment owner's approved secret-management process. + +## Administration API + +The minimal administration surface uses: + +- `GET /api/administration` +- `POST /api/administration/invitations` +- `DELETE /api/administration/invitations/:id` +- `PATCH /api/administration/memberships/:id` +- `GET` and `POST /api/administration/github/repositories` +- `PATCH` and `DELETE /api/administration/github/repositories/:repositorySourceId` +- `POST /api/administration/github/repositories/:repositorySourceId/reconcile` +- `POST /api/auth/bootstrap` +- `POST /api/auth/recovery` + +State-changing requests require the configured origin and matching CSRF cookie/header. Membership and invitation changes also require an active administrator session verified less than five minutes ago. Administration errors return stable codes such as `administrator_required`, `last_administrator`, and `invitation_changed`. + +## Security records + +Membership operations write a structured security record with a bounded action, outcome, actor, session, target membership or invitation, and fixed reason context. Success records commit with their state changes. Records never include host secrets, provider tokens, request payloads, mutable identity labels, or exception text. + +## Non-goals + +This boundary does not grant direct prototype, workspace, or artifact roles. The separate [prototype authorization](prototype-authorization.md) boundary defines product access and can consume a synchronized repository baseline below Hub overrides. Entra assignment controls Hub installation admission, not prototype authorization. Hub does not link accounts across providers, provision or deprovision users through SCIM, synchronize Graph groups after setup, or grant artifact publishing authority from a user-provider claim. diff --git a/hub/docs/prototype-authorization.md b/hub/docs/prototype-authorization.md new file mode 100644 index 00000000..31dec895 --- /dev/null +++ b/hub/docs/prototype-authorization.md @@ -0,0 +1,100 @@ +# Prototype authorization + +Hub owns prototype access after authentication and installation admission. A user does not need repository access to use a Hub-managed prototype. A prototype can explicitly select a linked GitHub repository as a synchronized baseline, but Hub remains the final evaluator. + +## Product records + +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 administrator may provision a prototype-scoped workload publisher. +- An authenticated workload publisher with `publish` creates the next version through the [artifact ingestion contract](artifact-ingestion.md). +- A version cannot move to another prototype or be changed or deleted after creation. +- Archiving a prototype keeps its versions readable but prevents new versions. Archive and artifact publication lock and re-read the prototype so overlapping requests cannot add a version after archival. + +Artifact payload validation and storage are owned by the separate [artifact ingestion layer](artifact-ingestion.md). User authorization controls who can provision the workload boundary and read versions; source provenance does not affect either decision. + +## 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, 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. + +## Hub overrides, synchronized baselines, and precedence + +Hub stores one direct workspace rule and one direct prototype rule per user and scope. A rule is either an explicit deny or an allowed role. Removing a rule restores evaluation from the remaining sources. + +Each prototype starts in `hub-managed` mode. An administrator may explicitly select `github-synchronized` mode and one active linked repository source. There is no implicit fallback from missing Hub access to GitHub. + +The evaluator applies this order: + +1. An explicit Hub deny at either the workspace or prototype scope denies access. +2. An explicit prototype role applies, including a lower role that intentionally restricts a stronger workspace role. +3. An explicit workspace role applies. +4. A fresh GitHub-synchronized baseline may apply. +5. Otherwise Hub denies access. + +Every decision reports whether access is allowed, the effective role, the source, and a stable reason. GitHub baseline decisions also report the repository source, immutable GitHub repository ID, raw permission, last confirmation time, and synchronization status. Routes use this evaluator instead of reimplementing precedence. Hub consults the synchronized baseline only when explicit Hub rules produce no decision, so an unavailable lower-precedence source cannot block an explicit deny or role. Prototype listing checks workspace access independently of the current page, so an authorized user receives an empty terminal page instead of an authorization error. + +Prototype read and list responses include `authorizationMode` and `sourceRepositoryId`. The authorization-policy mutation returns that same prototype shape, so administrators can inspect the effective configuration even when an explicit Hub role wins the access decision. + +The fixed mapping is: + +| GitHub permission | Hub prototype baseline | +| --- | --- | +| `admin`, `maintain`, `write` | `editor` | +| `triage` | `commenter` | +| `read` | `viewer` | +| `none` or missing collaborator | No access | + +GitHub never supplies Hub `administrator` authority. Manual workspace and prototype grants and denies live in separate tables and synchronization does not update or delete them. Converting a synchronized member to manual membership because a Hub administrator grants product administration writes a structured membership-preservation audit in the same transaction. + +Workspace administrators cannot remove or replace the final active workspace administrator. A prototype access change must also leave at least one active effective prototype administrator. Hub locks the owning records before checking these invariants, so overlapping changes cannot each remove a different final administrator. Suspended and removed users do not satisfy either invariant. + +Suspending or removing a membership fails with `product_administration_orphaned` when that user is the final active administrator for a workspace or prototype. An administrator must transfer product administration first. Changing an active installation administrator to a member does not remove an explicit product role, because installation and product authority remain separate. + +A workspace administrator can recover prototype access even when a prototype-specific administrator rule does not exist. Explicit prototype deny still blocks that administrator from the denied prototype until another workspace administrator changes the rule. Prototype lists omit prototypes that explicitly deny the requesting user. A workspace reachable through a direct prototype rule or synchronized prototype baseline is also readable, unless an explicit workspace deny blocks it. Workspace list responses contain only workspace fields and never embed prototype or access-rule metadata. + +## API and request boundaries + +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` +- `PUT /api/prototypes/:prototypeId/authorization` +- `GET /api/prototypes/:prototypeId/access/explanation` +- `PUT` and `DELETE /api/prototypes/:prototypeId/access/:userId` +- `GET /api/prototypes/:prototypeId/versions` +- `GET /api/prototypes/:prototypeId/versions/:versionId` +- `POST /api/prototypes/:prototypeId/publishers` +- `GET /api/prototypes/:prototypeId/publishers` +- `PATCH /api/prototypes/:prototypeId/publishers/:publisherId` +- `DELETE /api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId` + +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, workload publisher provisioning, role grants, explicit denies, and rule removal write structured security audits in the same transaction as the product change. Artifact publication writes its workload audit in the publication transaction. 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. + +## Synchronization lifecycle + +The [GitHub integration](github-integration.md) confirms permissions with installation credentials. Webhooks only mark relevant sources dirty or removed; periodic or manual reconciliation repairs missed events and confirms current permissions. A failed provider call does not refresh confirmation. Baselines remain usable only inside the configured one-day last-known-good window and fail closed afterward. + +Repository rename changes mutable owner, name, and default-branch fields without changing the immutable source identity. Repository removal, installation removal, and permission loss update synchronized baselines only. Direct Hub overrides remain intact. + +Source mutation and public links remain separate product boundaries. Artifact ingestion, promotion, retention, and rollback follow the provider-neutral contract without changing user access rules. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 4730dc20..e4a0569e 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -9,6 +9,7 @@ npm run lint npm run db:validate npm run typecheck npm test +npm run test:entra-setup npm run test:database npm run build npm run storybook:build @@ -16,7 +17,7 @@ npm run storybook:build `npm test` runs API and utility tests in Node.js and frontend tests in jsdom, which provides a browser-like environment. Neither requires PostgreSQL. To run a focused API check, use `npm test -- server/create-app/index.test.ts`. HTTP tests listen on temporary loopback ports and close their servers afterward. -The API health check proves only that Express responds. Routes that have not been built must return 404 rather than pretending to work. +The API health check proves only that Express responds. Authentication route tests inject provider clients and identity services; they never require live GitHub credentials or make real GitHub requests. Routes that have not been built must return 404 rather than pretending to work. Installation generates Prisma Client without a database connection. Client tests check reuse across development reloads, production behavior, and errors for missing configuration. @@ -34,6 +35,8 @@ Arrange each case's inputs and records inside that case. Act through its public Tests must release resources they create, such as servers, files, sockets, and event listeners. Use `try/finally` or register disposal with Vitest's `onTestFinished` when creating the resource. Clearing the module cache only affects subsequent imports. Reset any deliberately shared fixture state before each case. +Keep shared server test helpers under the owning function's `test/` directory. TypeScript checks them, but the API build excludes them. + ## Parallel database tests Each test run uses separate databases so parallel tests cannot change another worker's records or clear the development database. Vitest runs files in parallel in [forked worker processes](https://vitest.dev/config/pool). Cases within a worker run sequentially, including cases marked concurrent, so database resets cannot erase another case's records. Use `--maxWorkers` to set file-level parallelism. @@ -51,13 +54,21 @@ Use `.test.ts` or `.test.tsx` for ordinary cases. Use `.db.test.ts` or `.db.test Start the PostgreSQL service as described in [Development](development.md) and set `TEST_DATABASE_URL` using `.env.example`. The devcontainer supplies this variable. Use a local database user with permission to create and drop databases, never a production connection. -The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. There are no database test cases yet; an empty run does not prepare or exercise the databases. +The database command uses `--passWithNoTests` so an empty selection exits successfully. It still fails when tests fail. Identity repository cases exercise external identity linking and uniqueness, installation membership, provider-aware one-use attempts, session context, GitHub integration credential independence, transaction rollback, and concurrent store locks. Authentication and administration cases also cover PKCE, browser binding, provider revisions, invitation activation, one-use bootstrap and recovery boundaries, last-administrator races, targeted session revocation, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. + +Prototype authorization cases cover direct-rule precedence, explicit deny, no-access defaults, GitHub baseline mapping and staleness, prototype-specific restrictions, cross-prototype and cross-repository isolation, installation membership versus product authority, stale sessions, deterministic overlapping administrator changes, membership lifecycle conflicts, audit rollback, route enforcement, scalar list responses, deny-aware and terminal pagination, version ownership, workload credential revocation, publisher suspension, provisioning authority, lock-free metadata reads, archive lifecycle, and explainable decisions. Artifact publishing cases cover manifest bounds, credential-free provenance URLs, digest and size verification, credential expiry and forgery, scoped workload actions, token non-storage, idempotent replay and conflicts, status isolation, concurrent sequence assignment, immutable rows, atomic publish-and-promotion, promotion rollback and replay, revocation and disable races, cross-prototype isolation, audit rollback, body limits, request-log omission, and the local non-GitHub producer contract. Local storage cases reject normalized relative and symbolic-link paths and cover durable directory synchronization without requiring access above the configured root. + +GitHub synchronization cases use mocked GitHub API responses and real database transactions. They cover transactional cache revalidation, invitation precedence during source outage, immutable-ID rename and 404 invalidation, token-404 installation removal, credential-independent webhook revocation, retryable source-generation changes, per-result confirmation ordering, active-identity filtering, bounded provider requests and apply batches, partial scans, dirty-event retry coalescing, first-enrollment and admission-failure auditing, repository and installation lifecycle, provider-response versus apply failures, scheduled failure logging, stale per-user and overlapping reconciliation or sign-in, manual suspension and audited administration preservation, compatibility membership isolation, synchronized session freshness, and rollback. + +The API starts reconciliation every 15 minutes and operators can trigger one repository immediately through the administration API. A source stores `dirty`, `current`, `failed`, or `removed`, its last attempt and confirmation, and a bounded error code. Provider failures leave the prior confirmation unchanged. Access fails closed after the one-day last-known-good window, even if no webhook reported the change. Monitor repeated `failed` states and stale confirmations; do not extend the window to hide an outage. + +The reference artifact adapter stores content-addressed files under `HUB_ARTIFACT_STORAGE_PATH`. Give the test process a temporary absolute directory. Production deployments must mount durable storage and monitor capacity; the reference adapter has no replication or automatic garbage collection. Before deleting an unreferenced file, confirm that no `PrototypeVersion.storageKey` points to it and that no publish transaction or backup can still require it. 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: -1. Before workers start, setup creates a template database with a unique name for each project and run. It applies the current Prisma schema with `prisma db push`, then copies the template for every worker slot. +1. Before workers start, setup creates a template database with a unique name for each project and run. It applies the current Prisma schema with `prisma db push`, then copies the template for every worker slot. Tests that exercise a migration-only database invariant install that invariant in their isolated worker database. 2. Each worker receives its own `DATABASE_URL` before application modules load. Setup uses Vitest's [`VITEST_POOL_ID`](https://vitest.dev/guide/migration/jest#envs), which stays within the configured worker limit. 3. Before every case, setup empties application tables across user schemas and resets generated identity counters. It preserves Prisma's migration records. Each case creates its own records; there is no shared seed data. 4. After the run, teardown drops only the databases it created, even if tests left connections open. diff --git a/hub/package-lock.json b/hub/package-lock.json index 6594c6db..5048868b 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -15,6 +15,7 @@ "@prisma/client": "7.10.1", "dotenv": "18.0.8", "express": "5.3.0", + "jose": "6.2.12", "pino": "10.4.0", "pino-http": "11.0.0", "react": "19.3.0", @@ -6974,6 +6975,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha1-ZWY+FG7dAQuY7Og/gXN9Oqlf0Nc=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/hub/package.json b/hub/package.json index f24effed..1e1d6cae 100644 --- a/hub/package.json +++ b/hub/package.json @@ -3,6 +3,20 @@ "version": "0.1.0", "private": true, "type": "module", + "imports": { + "#server/*/constants": { + "development": "./server/*/constants.ts", + "default": "./dist/server/*/constants.js" + }, + "#server/*/types": { + "development": "./server/*/types.ts", + "default": "./dist/server/*/types.js" + }, + "#server/*": { + "development": "./server/*/index.ts", + "default": "./dist/server/*/index.js" + } + }, "engines": { "node": "^24.15.0 || ^26.0.0" }, @@ -22,9 +36,10 @@ "build": "npm run typecheck && vite build && tsc --project tsconfig.server.json", "dev": "npm run dev:client", "dev:client": "vite --host 127.0.0.1", - "dev:api": "node --watch server/index.ts", + "dev:api": "node --conditions=development --watch server/index.ts", "start": "node dist/server/index.js", "preview": "vite preview --host 127.0.0.1", + "publish:artifact": "node script/publish-artifact.mjs", "extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.mjs", "extension:build": "npm run extension:modules:build && vite build --config vite.prototype-canvas.config.mjs && node script/build-prototype-preview-bridge.mjs", "extension:modules:build": "node script/build-extension-modules.mjs", @@ -34,6 +49,7 @@ "storybook": "STORYBOOK_DISABLE_TELEMETRY=1 storybook dev --host 0.0.0.0 --port 6006 --initial-path /story/hub-design-overview--taxonomy", "storybook:build": "STORYBOOK_DISABLE_TELEMETRY=1 storybook build --output-dir storybook-static", "test": "vitest run --project=node --project=browser", + "test:entra-setup": "node --test script/tests/setup-entra-oidc.test.mjs", "test:database": "vitest run --project=node-database --project=browser-database --passWithNoTests", "typecheck": "tsc --noEmit && tsc --project ../.github/extensions/replica-prototype/tsconfig.json" }, @@ -44,6 +60,7 @@ "@primer/react-brand": "0.78.0", "dotenv": "18.0.8", "express": "5.3.0", + "jose": "6.2.12", "pino": "10.4.0", "pino-http": "11.0.0", "react": "19.3.0", 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/artifact-publication.prisma b/hub/schema/artifact-publication.prisma new file mode 100644 index 00000000..9b42b51f --- /dev/null +++ b/hub/schema/artifact-publication.prisma @@ -0,0 +1,24 @@ +model ArtifactPublication { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + workloadPublisherId String @map("workload_publisher_id") @db.Uuid + prototypeVersionId String @unique @map("prototype_version_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + workloadPublisher WorkloadPublisher @relation(fields: [workloadPublisherId], references: [id], onDelete: Restrict) + prototypeVersion PrototypeVersion @relation(fields: [prototypeVersionId], references: [id], onDelete: Restrict) + + // Attribute columns + idempotencyKeyDigest String @map("idempotency_key_digest") + requestDigest String @map("request_digest") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([workloadPublisherId, idempotencyKeyDigest]) + @@index([prototypeId]) + @@map("artifact_publications") +} diff --git a/hub/schema/authentication-provider.prisma b/hub/schema/authentication-provider.prisma new file mode 100644 index 00000000..4e9b4a32 --- /dev/null +++ b/hub/schema/authentication-provider.prisma @@ -0,0 +1,42 @@ +model AuthenticationProvider { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + revisions AuthenticationProviderRevision[] + + // Attribute columns + key String @unique + type String + enabled Boolean @default(true) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("authentication_providers") +} + +model AuthenticationProviderRevision { + // ID columns + id String @id @default(uuid()) @db.Uuid + authenticationProviderId String @map("authentication_provider_id") @db.Uuid + + // Relations + authenticationProvider AuthenticationProvider @relation(fields: [authenticationProviderId], references: [id], onDelete: Cascade) + authorizationAttempts AuthorizationAttempt[] + sessions Session[] + + // Attribute columns + revision Int + issuer String + configuration Json + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([authenticationProviderId, revision]) + @@index([issuer]) + @@map("authentication_provider_revisions") +} diff --git a/hub/schema/authorization-attempt.prisma b/hub/schema/authorization-attempt.prisma new file mode 100644 index 00000000..251c6fde --- /dev/null +++ b/hub/schema/authorization-attempt.prisma @@ -0,0 +1,28 @@ +model AuthorizationAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + authenticationProviderRevisionId String @map("authentication_provider_revision_id") @db.Uuid + originatingSessionId String? @map("originating_session_id") @db.Uuid + + // Relations + authenticationProviderRevision AuthenticationProviderRevision @relation(fields: [authenticationProviderRevisionId], references: [id], onDelete: Cascade) + originatingSession Session? @relation("OriginatingSession", fields: [originatingSessionId], references: [id], onDelete: Cascade) + + // Attribute columns + stateDigest String @unique @map("state_digest") + browserDigest String @map("browser_digest") + pkceVerifier String @map("pkce_verifier") + nonce String? + purpose String + sessionGeneration Int @default(0) @map("session_generation") + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([expiresAt]) + @@index([authenticationProviderRevisionId]) + @@index([originatingSessionId]) + @@map("authorization_attempts") +} diff --git a/hub/schema/external-identity.prisma b/hub/schema/external-identity.prisma new file mode 100644 index 00000000..6e5b4fdc --- /dev/null +++ b/hub/schema/external-identity.prisma @@ -0,0 +1,29 @@ +model ExternalIdentity { + // ID columns + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sessions Session[] + activatedInvitations MembershipInvitation[] @relation("ActivatedIdentity") + + // Attribute columns + provider String + issuer String + subject String + directoryObjectId String? @map("directory_object_id") + username String? + email String? + displayName String? @map("display_name") + avatarUrl String? @map("avatar_url") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([provider, issuer, subject]) + @@unique([provider, issuer, directoryObjectId]) + @@index([userId]) + @@map("external_identities") +} diff --git a/hub/schema/github-admission-attempt.prisma b/hub/schema/github-admission-attempt.prisma new file mode 100644 index 00000000..0c2603d0 --- /dev/null +++ b/hub/schema/github-admission-attempt.prisma @@ -0,0 +1,19 @@ +model GitHubAdmissionAttempt { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String @map("repository_source_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource @relation(fields: [repositorySourceId], references: [id], onDelete: Cascade) + + // Attribute columns + subject String + generation Int @default(0) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([repositorySourceId, subject]) + @@map("github_admission_attempts") +} diff --git a/hub/schema/github-authorization-policy.prisma b/hub/schema/github-authorization-policy.prisma new file mode 100644 index 00000000..3683cedb --- /dev/null +++ b/hub/schema/github-authorization-policy.prisma @@ -0,0 +1,13 @@ +model GitHubAuthorizationPolicy { + // ID columns + id String @id @db.Uuid + + // Attribute columns + lastKnownGoodSeconds Int @default(86400) @map("last_known_good_seconds") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("github_authorization_policies") +} diff --git a/hub/schema/github-integration-state.prisma b/hub/schema/github-integration-state.prisma new file mode 100644 index 00000000..7349ad5a --- /dev/null +++ b/hub/schema/github-integration-state.prisma @@ -0,0 +1,14 @@ +model GitHubIntegrationState { + // ID columns + id String @id @db.Uuid + + // Attribute columns + generation Int @default(0) + encryptedCredentials String? @map("encrypted_credentials") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("github_integration_states") +} diff --git a/hub/schema/github-repository-source.prisma b/hub/schema/github-repository-source.prisma new file mode 100644 index 00000000..7113a058 --- /dev/null +++ b/hub/schema/github-repository-source.prisma @@ -0,0 +1,36 @@ +model GitHubRepositorySource { + // ID columns + id String @id @default(uuid()) @db.Uuid + githubRepositoryId String @unique @map("github_repository_id") + + // Relations + prototypes Prototype[] + synchronizedAccess GitHubSynchronizedAccess[] + admissionAttempts GitHubAdmissionAttempt[] + webhookDeliveries GitHubWebhookDelivery[] + auditTargets SecurityAudit[] + + // Attribute columns + appId String @map("app_id") + installationId String @map("installation_id") + owner String + name String + defaultBranch String @map("default_branch") + visibility String @default("unknown") + status String @default("active") + admissionEnabled Boolean @default(false) @map("admission_enabled") + synchronizationGeneration Int @default(0) @map("synchronization_generation") + synchronizationStatus String @default("dirty") @map("synchronization_status") + synchronizationError String? @map("synchronization_error") + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + dirtyAt DateTime? @map("dirty_at") @db.Timestamptz(3) + lastAttemptedAt DateTime? @map("last_attempted_at") @db.Timestamptz(3) + lastConfirmedAt DateTime? @map("last_confirmed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([installationId, status]) + @@map("github_repository_sources") +} diff --git a/hub/schema/github-synchronized-access.prisma b/hub/schema/github-synchronized-access.prisma new file mode 100644 index 00000000..44fe6e8c --- /dev/null +++ b/hub/schema/github-synchronized-access.prisma @@ -0,0 +1,28 @@ +model GitHubSynchronizedAccess { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String @map("repository_source_id") @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource @relation(fields: [repositorySourceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + + // Attribute columns + rawPermission String @map("raw_permission") + mappedPrototypeRole PrototypeRole? @map("mapped_prototype_role") + admissionEligible Boolean @map("admission_eligible") + status String @default("active") + source String @default("github_app_installation") + synchronizationStatus String @default("current") @map("synchronization_status") + lastConfirmedAt DateTime @map("last_confirmed_at") @db.Timestamptz(3) + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([repositorySourceId, userId]) + @@index([userId, status]) + @@map("github_synchronized_access") +} diff --git a/hub/schema/github-webhook-delivery.prisma b/hub/schema/github-webhook-delivery.prisma new file mode 100644 index 00000000..39f1316c --- /dev/null +++ b/hub/schema/github-webhook-delivery.prisma @@ -0,0 +1,21 @@ +model GitHubWebhookDelivery { + // ID columns + id String @id @default(uuid()) @db.Uuid + repositorySourceId String? @map("repository_source_id") @db.Uuid + + // Relations + repositorySource GitHubRepositorySource? @relation(fields: [repositorySourceId], references: [id], onDelete: SetNull) + + // Attribute columns + deliveryId String @unique @map("delivery_id") + event String + action String? + installationId String @map("installation_id") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([installationId, createdAt]) + @@map("github_webhook_deliveries") +} diff --git a/hub/schema/identity-store-state.prisma b/hub/schema/identity-store-state.prisma new file mode 100644 index 00000000..badf58f7 --- /dev/null +++ b/hub/schema/identity-store-state.prisma @@ -0,0 +1,14 @@ +model IdentityStoreState { + // ID columns + id String @id @db.Uuid + + // Attribute columns + sessionGeneration Int @default(0) @map("session_generation") + bootstrapCompletedAt DateTime? @map("bootstrap_completed_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) + + @@map("identity_store_states") +} diff --git a/hub/schema/installation-membership.prisma b/hub/schema/installation-membership.prisma new file mode 100644 index 00000000..623d965f --- /dev/null +++ b/hub/schema/installation-membership.prisma @@ -0,0 +1,24 @@ +model InstallationMembership { + // ID columns + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Restrict) + invitations MembershipInvitation[] @relation("ActivatedMembership") + auditTargets SecurityAudit[] @relation("TargetMembership") + + // Attribute columns + status String + role String + source String + suspendedAt DateTime? @map("suspended_at") @db.Timestamptz(3) + removedAt DateTime? @map("removed_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@unique([userId]) + @@map("installation_memberships") +} diff --git a/hub/schema/membership-invitation.prisma b/hub/schema/membership-invitation.prisma new file mode 100644 index 00000000..3e8e61e2 --- /dev/null +++ b/hub/schema/membership-invitation.prisma @@ -0,0 +1,31 @@ +model MembershipInvitation { + // ID columns + id String @id @default(uuid()) @db.Uuid + createdByUserId String? @map("created_by_user_id") @db.Uuid + activatedIdentityId String? @map("activated_identity_id") @db.Uuid + activatedMembershipId String? @map("activated_membership_id") @db.Uuid + + // Relations + createdByUser User? @relation("InvitationCreator", fields: [createdByUserId], references: [id], onDelete: SetNull) + activatedIdentity ExternalIdentity? @relation("ActivatedIdentity", fields: [activatedIdentityId], references: [id], onDelete: SetNull) + activatedMembership InstallationMembership? @relation("ActivatedMembership", fields: [activatedMembershipId], references: [id], onDelete: SetNull) + audits SecurityAudit[] + + // Attribute columns + provider String + issuer String + subject String + role String + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_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([provider, issuer, subject]) + @@index([expiresAt]) + @@index([createdByUserId]) + @@map("membership_invitations") +} diff --git a/hub/schema/migrations/20261026195151_identity/migration.sql b/hub/schema/migrations/20261026195151_identity/migration.sql new file mode 100644 index 00000000..602477f6 --- /dev/null +++ b/hub/schema/migrations/20261026195151_identity/migration.sql @@ -0,0 +1,203 @@ +-- CreateTable +CREATE TABLE "authentication_providers" ( + "id" UUID NOT NULL, + "key" TEXT NOT NULL, + "type" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_providers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "authentication_provider_revisions" ( + "id" UUID NOT NULL, + "authentication_provider_id" UUID NOT NULL, + "revision" INTEGER NOT NULL, + "issuer" TEXT NOT NULL, + "configuration" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authentication_provider_revisions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "identity_store_states" ( + "id" UUID NOT NULL, + "session_generation" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "identity_store_states_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "github_integration_states" ( + "id" UUID NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "encrypted_credentials" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_integration_states_pkey" PRIMARY KEY ("id") +); + +-- SeedData +INSERT INTO "github_integration_states" ("id") VALUES ('00000000-0000-4000-8000-000000000001'); + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "display_name" TEXT, + "avatar_url" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "external_identities" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "username" TEXT, + "display_name" TEXT, + "avatar_url" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "external_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "installation_memberships" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "status" TEXT NOT NULL, + "role" TEXT NOT NULL, + "source" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "installation_memberships_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "external_identity_id" UUID NOT NULL, + "authentication_provider_revision_id" UUID NOT NULL, + "token_digest" TEXT NOT NULL, + "signed_in_at" TIMESTAMPTZ(3) NOT NULL, + "fresh_authorized_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "authorization_attempts" ( + "id" UUID NOT NULL, + "authentication_provider_revision_id" UUID NOT NULL, + "originating_session_id" UUID, + "state_digest" TEXT NOT NULL, + "browser_digest" TEXT NOT NULL, + "pkce_verifier" TEXT NOT NULL, + "nonce" TEXT, + "purpose" TEXT NOT NULL, + "session_generation" INTEGER NOT NULL DEFAULT 0, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "authorization_attempts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "security_audits" ( + "id" UUID NOT NULL, + "actor_user_id" UUID, + "session_id" UUID, + "authentication_provider_revision_id" UUID, + "event" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "security_audits_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "authentication_providers_key_key" ON "authentication_providers"("key"); + +-- CreateIndex +CREATE INDEX "authentication_provider_revisions_issuer_idx" ON "authentication_provider_revisions"("issuer"); + +-- CreateIndex +CREATE UNIQUE INDEX "authentication_provider_revisions_authentication_provider_i_key" ON "authentication_provider_revisions"("authentication_provider_id", "revision"); + +-- CreateIndex +CREATE INDEX "external_identities_user_id_idx" ON "external_identities"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "external_identities_provider_issuer_subject_key" ON "external_identities"("provider", "issuer", "subject"); + +-- CreateIndex +CREATE UNIQUE INDEX "installation_memberships_user_id_key" ON "installation_memberships"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_token_digest_key" ON "sessions"("token_digest"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "sessions_external_identity_id_idx" ON "sessions"("external_identity_id"); + +-- CreateIndex +CREATE INDEX "sessions_authentication_provider_revision_id_idx" ON "sessions"("authentication_provider_revision_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "authorization_attempts_state_digest_key" ON "authorization_attempts"("state_digest"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_expires_at_idx" ON "authorization_attempts"("expires_at"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_authentication_provider_revision_id_idx" ON "authorization_attempts"("authentication_provider_revision_id"); + +-- CreateIndex +CREATE INDEX "authorization_attempts_originating_session_id_idx" ON "authorization_attempts"("originating_session_id"); + +-- CreateIndex +CREATE INDEX "security_audits_created_at_idx" ON "security_audits"("created_at"); + +-- AddForeignKey +ALTER TABLE "authentication_provider_revisions" ADD CONSTRAINT "authentication_provider_revisions_authentication_provider__fkey" FOREIGN KEY ("authentication_provider_id") REFERENCES "authentication_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "external_identities" ADD CONSTRAINT "external_identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_external_identity_id_fkey" FOREIGN KEY ("external_identity_id") REFERENCES "external_identities"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_authentication_provider_revision_id_fkey" FOREIGN KEY ("authentication_provider_revision_id") REFERENCES "authentication_provider_revisions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "authorization_attempts" ADD CONSTRAINT "authorization_attempts_authentication_provider_revision_id_fkey" FOREIGN KEY ("authentication_provider_revision_id") REFERENCES "authentication_provider_revisions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "authorization_attempts" ADD CONSTRAINT "authorization_attempts_originating_session_id_fkey" FOREIGN KEY ("originating_session_id") REFERENCES "sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/schema/migrations/20261027210000_membership_administration/migration.sql b/hub/schema/migrations/20261027210000_membership_administration/migration.sql new file mode 100644 index 00000000..55025496 --- /dev/null +++ b/hub/schema/migrations/20261027210000_membership_administration/migration.sql @@ -0,0 +1,98 @@ +-- AlterTable +ALTER TABLE "identity_store_states" ADD COLUMN "bootstrap_completed_at" TIMESTAMPTZ(3); + +-- AlterTable +ALTER TABLE "installation_memberships" +ADD COLUMN "suspended_at" TIMESTAMPTZ(3), +ADD COLUMN "removed_at" TIMESTAMPTZ(3); + +-- Existing foundation records are active and must satisfy the lifecycle constraint. +UPDATE "installation_memberships" +SET "suspended_at" = CASE WHEN "status" = 'suspended' THEN CURRENT_TIMESTAMP ELSE NULL END; + +-- Replace the cascading user relation so retained membership and audit linkage cannot disappear. +ALTER TABLE "installation_memberships" DROP CONSTRAINT "installation_memberships_user_id_fkey"; +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_status" CHECK ("status" IN ('active', 'suspended', 'removed')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_role" CHECK ("role" IN ('member', 'administrator')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_source" CHECK ("source" IN ('bootstrap', 'invitation', 'manual', 'recovery', 'synchronized')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_lifecycle" CHECK ( + ("status" = 'active' AND "suspended_at" IS NULL AND "removed_at" IS NULL) OR + ("status" = 'suspended' AND "suspended_at" IS NOT NULL AND "removed_at" IS NULL) OR + ("status" = 'removed' AND "removed_at" IS NOT NULL) +); + +-- CreateTable +CREATE TABLE "membership_invitations" ( + "id" UUID NOT NULL, + "created_by_user_id" UUID, + "activated_identity_id" UUID, + "activated_membership_id" UUID, + "provider" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "role" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "consumed_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "membership_invitations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "membership_invitations_role" CHECK ("role" IN ('member', 'administrator')), + CONSTRAINT "membership_invitations_lifecycle" CHECK (NOT ("consumed_at" IS NOT NULL AND "revoked_at" IS NOT NULL)) +); + +-- CreateIndex +CREATE INDEX "membership_invitations_provider_issuer_subject_idx" ON "membership_invitations"("provider", "issuer", "subject"); + +-- CreateIndex +CREATE INDEX "membership_invitations_expires_at_idx" ON "membership_invitations"("expires_at"); + +-- CreateIndex +CREATE INDEX "membership_invitations_created_by_user_id_idx" ON "membership_invitations"("created_by_user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "membership_invitations_usable_assignment_key" ON "membership_invitations"("provider", "issuer", "subject") WHERE "consumed_at" IS NULL AND "revoked_at" IS NULL; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_activated_identity_id_fkey" FOREIGN KEY ("activated_identity_id") REFERENCES "external_identities"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_activated_membership_id_fkey" FOREIGN KEY ("activated_membership_id") REFERENCES "installation_memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "security_audits" +ADD COLUMN "target_membership_id" UUID, +ADD COLUMN "invitation_id" UUID, +ADD COLUMN "outcome" TEXT NOT NULL DEFAULT 'succeeded', +ADD COLUMN "context" JSONB NOT NULL DEFAULT '{}'; + +-- AddCheckConstraint +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_outcome" CHECK ("outcome" IN ('succeeded', 'denied', 'failed')); + +-- AddCheckConstraint +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_context" CHECK (jsonb_typeof("context") = 'object'); + +-- CreateIndex +CREATE INDEX "security_audits_target_membership_id_idx" ON "security_audits"("target_membership_id"); + +-- CreateIndex +CREATE INDEX "security_audits_invitation_id_idx" ON "security_audits"("invitation_id"); + +-- AddForeignKey +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_target_membership_id_fkey" FOREIGN KEY ("target_membership_id") REFERENCES "installation_memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_invitation_id_fkey" FOREIGN KEY ("invitation_id") REFERENCES "membership_invitations"("id") ON DELETE SET NULL ON UPDATE CASCADE; 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/migrations/20261027230000_artifact_ingestion/migration.sql b/hub/schema/migrations/20261027230000_artifact_ingestion/migration.sql new file mode 100644 index 00000000..baf29a19 --- /dev/null +++ b/hub/schema/migrations/20261027230000_artifact_ingestion/migration.sql @@ -0,0 +1,125 @@ +-- CreateTable +CREATE TABLE "workload_publishers" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "actions" TEXT[] NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workload_publishers_pkey" PRIMARY KEY ("id"), + CONSTRAINT "workload_publishers_actions" CHECK ( + cardinality("actions") BETWEEN 1 AND 2 AND + "actions" <@ ARRAY['publish', 'read-status']::TEXT[] AND + "actions" <> ARRAY['publish', 'publish']::TEXT[] AND + "actions" <> ARRAY['read-status', 'read-status']::TEXT[] + ) +); + +-- CreateTable +CREATE TABLE "workload_credentials" ( + "id" UUID NOT NULL, + "workload_publisher_id" UUID NOT NULL, + "token_digest" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workload_credentials_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "prototype_versions" +ALTER COLUMN "created_by_user_id" DROP NOT NULL, +ADD COLUMN "workload_publisher_id" UUID, +ADD COLUMN "artifact_format" TEXT, +ADD COLUMN "artifact_version" TEXT, +ADD COLUMN "content_digest" TEXT, +ADD COLUMN "size_bytes" INTEGER, +ADD COLUMN "built_at" TIMESTAMPTZ(3), +ADD COLUMN "provenance_provider" TEXT, +ADD COLUMN "provenance_source_url" TEXT, +ADD COLUMN "provenance_revision" TEXT, +ADD COLUMN "provenance_ref" TEXT, +ADD COLUMN "provenance_pipeline" TEXT, +ADD COLUMN "provenance_run_id" TEXT, +ADD COLUMN "storage_key" TEXT, +ADD CONSTRAINT "prototype_versions_single_creator" CHECK ( + ("created_by_user_id" IS NOT NULL) <> ("workload_publisher_id" IS NOT NULL) +), +ADD CONSTRAINT "prototype_versions_published_artifact" CHECK ( + "workload_publisher_id" IS NULL OR ( + "artifact_format" IS NOT NULL AND + "artifact_version" IS NOT NULL AND + "content_digest" IS NOT NULL AND + "size_bytes" IS NOT NULL AND + "built_at" IS NOT NULL AND + "provenance_provider" IS NOT NULL AND + "storage_key" IS NOT NULL + ) +); + +-- CreateTable +CREATE TABLE "artifact_publications" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "workload_publisher_id" UUID NOT NULL, + "prototype_version_id" UUID NOT NULL, + "idempotency_key_digest" TEXT NOT NULL, + "request_digest" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "artifact_publications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "prototype_channels" ( + "id" UUID NOT NULL, + "prototype_id" UUID NOT NULL, + "prototype_version_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 "prototype_channels_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "security_audits" ADD COLUMN "workload_publisher_id" UUID; + +-- CreateIndex +CREATE INDEX "workload_publishers_prototype_id_idx" ON "workload_publishers"("prototype_id"); +CREATE UNIQUE INDEX "workload_credentials_token_digest_key" ON "workload_credentials"("token_digest"); +CREATE INDEX "workload_credentials_workload_publisher_id_idx" ON "workload_credentials"("workload_publisher_id"); +CREATE INDEX "prototype_versions_workload_publisher_id_idx" ON "prototype_versions"("workload_publisher_id"); +CREATE UNIQUE INDEX "prototype_versions_prototype_id_content_digest_key" ON "prototype_versions"("prototype_id", "content_digest"); +CREATE UNIQUE INDEX "artifact_publications_prototype_version_id_key" ON "artifact_publications"("prototype_version_id"); +CREATE UNIQUE INDEX "artifact_publications_workload_publisher_id_idempotency_key_digest_key" ON "artifact_publications"("workload_publisher_id", "idempotency_key_digest"); +CREATE INDEX "artifact_publications_prototype_id_idx" ON "artifact_publications"("prototype_id"); +CREATE UNIQUE INDEX "prototype_channels_prototype_id_name_key" ON "prototype_channels"("prototype_id", "name"); +CREATE INDEX "prototype_channels_prototype_version_id_idx" ON "prototype_channels"("prototype_version_id"); +CREATE INDEX "security_audits_workload_publisher_id_idx" ON "security_audits"("workload_publisher_id"); + +-- AddForeignKey +ALTER TABLE "workload_publishers" ADD CONSTRAINT "workload_publishers_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "workload_credentials" ADD CONSTRAINT "workload_credentials_workload_publisher_id_fkey" FOREIGN KEY ("workload_publisher_id") REFERENCES "workload_publishers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_versions" ADD CONSTRAINT "prototype_versions_workload_publisher_id_fkey" FOREIGN KEY ("workload_publisher_id") REFERENCES "workload_publishers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "artifact_publications" ADD CONSTRAINT "artifact_publications_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "artifact_publications" ADD CONSTRAINT "artifact_publications_workload_publisher_id_fkey" FOREIGN KEY ("workload_publisher_id") REFERENCES "workload_publishers"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "artifact_publications" ADD CONSTRAINT "artifact_publications_prototype_version_id_fkey" FOREIGN KEY ("prototype_version_id") REFERENCES "prototype_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_channels" ADD CONSTRAINT "prototype_channels_prototype_id_fkey" FOREIGN KEY ("prototype_id") REFERENCES "prototypes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "prototype_channels" ADD CONSTRAINT "prototype_channels_prototype_version_id_fkey" FOREIGN KEY ("prototype_version_id") REFERENCES "prototype_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Immutable prototype versions may be inserted and read, but never rewritten or deleted. +CREATE FUNCTION reject_prototype_version_mutation() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'prototype versions are immutable'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER prototype_versions_immutable +BEFORE UPDATE OR DELETE ON "prototype_versions" +FOR EACH ROW EXECUTE FUNCTION reject_prototype_version_mutation(); diff --git a/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql new file mode 100644 index 00000000..47d1f6f1 --- /dev/null +++ b/hub/schema/migrations/20261027230000_github_synchronized_authorization/migration.sql @@ -0,0 +1,153 @@ +-- CreateTable +CREATE TABLE "github_authorization_policies" ( + "id" UUID NOT NULL, + "last_known_good_seconds" INTEGER NOT NULL DEFAULT 86400, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_authorization_policies_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_authorization_policies_last_known_good_seconds" CHECK ( + "last_known_good_seconds" BETWEEN 0 AND 604800 + ) +); + +INSERT INTO "github_authorization_policies" ("id") +VALUES ('00000000-0000-0000-0000-000000000001'); + +ALTER TABLE "installation_memberships" +DROP CONSTRAINT "installation_memberships_source"; + +ALTER TABLE "installation_memberships" +ADD CONSTRAINT "installation_memberships_source" +CHECK ("source" IN ( + 'bootstrap', + 'invitation', + 'legacy-synchronized', + 'manual', + 'recovery', + 'synchronized' +)); + +UPDATE "installation_memberships" +SET "source" = 'legacy-synchronized' +WHERE "source" = 'synchronized'; + +-- CreateTable +CREATE TABLE "github_repository_sources" ( + "id" UUID NOT NULL, + "github_repository_id" TEXT NOT NULL, + "app_id" TEXT NOT NULL, + "installation_id" TEXT NOT NULL, + "owner" TEXT NOT NULL, + "name" TEXT NOT NULL, + "default_branch" TEXT NOT NULL, + "visibility" TEXT NOT NULL DEFAULT 'unknown', + "status" TEXT NOT NULL DEFAULT 'active', + "admission_enabled" BOOLEAN NOT NULL DEFAULT false, + "synchronization_generation" INTEGER NOT NULL DEFAULT 0, + "synchronization_status" TEXT NOT NULL DEFAULT 'dirty', + "synchronization_error" TEXT, + "removed_at" TIMESTAMPTZ(3), + "dirty_at" TIMESTAMPTZ(3), + "last_attempted_at" TIMESTAMPTZ(3), + "last_confirmed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_repository_sources_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_repository_sources_status" CHECK ( + "status" IN ('active', 'removed', 'suspended') + ), + CONSTRAINT "github_repository_sources_synchronization_generation" CHECK ( + "synchronization_generation" >= 0 + ), + CONSTRAINT "github_repository_sources_synchronization_status" CHECK ( + "synchronization_status" IN ('dirty', 'current', 'failed', 'removed') + ), + CONSTRAINT "github_repository_sources_visibility" CHECK ( + "visibility" IN ('internal', 'private', 'public', 'unknown') + ) +); + +-- CreateTable +CREATE TABLE "github_admission_attempts" ( + "id" UUID NOT NULL, + "repository_source_id" UUID NOT NULL, + "subject" TEXT NOT NULL, + "generation" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_admission_attempts_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_admission_attempts_generation" CHECK ("generation" >= 0) +); + +-- CreateTable +CREATE TABLE "github_synchronized_access" ( + "id" UUID NOT NULL, + "repository_source_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "raw_permission" TEXT NOT NULL, + "mapped_prototype_role" "PrototypeRole", + "admission_eligible" BOOLEAN NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "source" TEXT NOT NULL DEFAULT 'github_app_installation', + "synchronization_status" TEXT NOT NULL DEFAULT 'current', + "last_confirmed_at" TIMESTAMPTZ(3) NOT NULL, + "removed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_synchronized_access_pkey" PRIMARY KEY ("id"), + CONSTRAINT "github_synchronized_access_raw_permission" CHECK ( + "raw_permission" IN ('admin', 'maintain', 'write', 'triage', 'read', 'none') + ), + CONSTRAINT "github_synchronized_access_status" CHECK ("status" IN ('active', 'removed')), + CONSTRAINT "github_synchronized_access_synchronization_status" CHECK ( + "synchronization_status" IN ('current', 'dirty', 'failed', 'removed') + ) +); + +-- CreateTable +CREATE TABLE "github_webhook_deliveries" ( + "id" UUID NOT NULL, + "repository_source_id" UUID, + "delivery_id" TEXT NOT NULL, + "event" TEXT NOT NULL, + "action" TEXT, + "installation_id" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "github_webhook_deliveries_pkey" PRIMARY KEY ("id") +); + +-- AlterTable +ALTER TABLE "prototypes" +ADD COLUMN "authorization_mode" TEXT NOT NULL DEFAULT 'hub-managed', +ADD COLUMN "source_repository_id" UUID, +ADD CONSTRAINT "prototypes_authorization_mode" CHECK ( + "authorization_mode" IN ('hub-managed', 'github-synchronized') +); + +ALTER TABLE "security_audits" +ADD COLUMN "github_repository_source_id" UUID; + +-- CreateIndex +CREATE UNIQUE INDEX "github_repository_sources_github_repository_id_key" ON "github_repository_sources"("github_repository_id"); +CREATE UNIQUE INDEX "github_admission_attempts_repository_source_id_subject_key" ON "github_admission_attempts"("repository_source_id", "subject"); +CREATE INDEX "github_repository_sources_installation_id_status_idx" ON "github_repository_sources"("installation_id", "status"); +CREATE UNIQUE INDEX "github_synchronized_access_repository_source_id_user_id_key" ON "github_synchronized_access"("repository_source_id", "user_id"); +CREATE INDEX "github_synchronized_access_user_id_status_idx" ON "github_synchronized_access"("user_id", "status"); +CREATE UNIQUE INDEX "github_webhook_deliveries_delivery_id_key" ON "github_webhook_deliveries"("delivery_id"); +CREATE INDEX "github_webhook_deliveries_installation_id_created_at_idx" ON "github_webhook_deliveries"("installation_id", "created_at"); +CREATE INDEX "prototypes_source_repository_id_idx" ON "prototypes"("source_repository_id"); +CREATE INDEX "security_audits_github_repository_source_id_idx" ON "security_audits"("github_repository_source_id"); + +-- AddForeignKey +ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "github_admission_attempts" ADD CONSTRAINT "github_admission_attempts_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "github_synchronized_access" ADD CONSTRAINT "github_synchronized_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "github_webhook_deliveries" ADD CONSTRAINT "github_webhook_deliveries_repository_source_id_fkey" FOREIGN KEY ("repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "prototypes" ADD CONSTRAINT "prototypes_source_repository_id_fkey" FOREIGN KEY ("source_repository_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_github_repository_source_id_fkey" FOREIGN KEY ("github_repository_source_id") REFERENCES "github_repository_sources"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/schema/migrations/20261027231000_publisher_lifecycle/migration.sql b/hub/schema/migrations/20261027231000_publisher_lifecycle/migration.sql new file mode 100644 index 00000000..bbac9505 --- /dev/null +++ b/hub/schema/migrations/20261027231000_publisher_lifecycle/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "security_audits" ADD COLUMN "workload_credential_id" UUID; + +-- CreateIndex +CREATE INDEX "security_audits_workload_credential_id_idx" ON "security_audits"("workload_credential_id"); diff --git a/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql b/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql new file mode 100644 index 00000000..164ff676 --- /dev/null +++ b/hub/schema/migrations/20261028210000_oidc_review_hardening/migration.sql @@ -0,0 +1,21 @@ +ALTER TABLE "external_identities" +ADD COLUMN "email" TEXT, +ADD COLUMN "directory_object_id" TEXT; + +CREATE UNIQUE INDEX "external_identities_provider_issuer_directory_object_id_key" +ON "external_identities"("provider", "issuer", "directory_object_id"); + +ALTER TABLE "installation_memberships" +DROP CONSTRAINT "installation_memberships_source"; + +ALTER TABLE "installation_memberships" +ADD CONSTRAINT "installation_memberships_source" +CHECK ("source" IN ( + 'bootstrap', + 'invitation', + 'legacy-synchronized', + 'manual', + 'oidc-assignment', + 'recovery', + 'synchronized' +)); diff --git a/hub/schema/migrations/migration_lock.toml b/hub/schema/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/hub/schema/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/hub/schema/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-channel.prisma b/hub/schema/prototype-channel.prisma new file mode 100644 index 00000000..b650e614 --- /dev/null +++ b/hub/schema/prototype-channel.prisma @@ -0,0 +1,21 @@ +model PrototypeChannel { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + prototypeVersionId String @map("prototype_version_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + prototypeVersion PrototypeVersion @relation(fields: [prototypeVersionId], references: [id], onDelete: Restrict) + + // 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) + + @@unique([prototypeId, name]) + @@index([prototypeVersionId]) + @@map("prototype_channels") +} 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..4210e28c --- /dev/null +++ b/hub/schema/prototype-version.prisma @@ -0,0 +1,40 @@ +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 + workloadPublisherId String? @map("workload_publisher_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + createdBy User? @relation("PrototypeVersionCreator", fields: [createdByUserId], references: [id], onDelete: Restrict) + workloadPublisher WorkloadPublisher? @relation(fields: [workloadPublisherId], references: [id], onDelete: Restrict) + publication ArtifactPublication? + channels PrototypeChannel[] + + // Attribute columns + sequence Int + label String? + artifactFormat String? @map("artifact_format") + artifactVersion String? @map("artifact_version") + contentDigest String? @map("content_digest") + sizeBytes Int? @map("size_bytes") + builtAt DateTime? @map("built_at") @db.Timestamptz(3) + provenanceProvider String? @map("provenance_provider") + provenanceSourceUrl String? @map("provenance_source_url") + provenanceRevision String? @map("provenance_revision") + provenanceRef String? @map("provenance_ref") + provenancePipeline String? @map("provenance_pipeline") + provenanceRunId String? @map("provenance_run_id") + storageKey String? @map("storage_key") + + // 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]) + @@unique([prototypeId, contentDigest]) + @@index([createdByUserId]) + @@index([workloadPublisherId]) + @@map("prototype_versions") +} diff --git a/hub/schema/prototype.prisma b/hub/schema/prototype.prisma new file mode 100644 index 00000000..30dbb25a --- /dev/null +++ b/hub/schema/prototype.prisma @@ -0,0 +1,31 @@ +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[] + sourceRepository GitHubRepositorySource? @relation(fields: [sourceRepositoryId], references: [id], onDelete: SetNull) + publishers WorkloadPublisher[] + publications ArtifactPublication[] + channels PrototypeChannel[] + + // Attribute columns + name String + authorizationMode String @default("hub-managed") @map("authorization_mode") + sourceRepositoryId String? @map("source_repository_id") @db.Uuid + archivedAt DateTime? @map("archived_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([workspaceId]) + @@index([createdByUserId]) + @@index([sourceRepositoryId]) + @@map("prototypes") +} diff --git a/hub/schema/security-audit.prisma b/hub/schema/security-audit.prisma new file mode 100644 index 00000000..7075b111 --- /dev/null +++ b/hub/schema/security-audit.prisma @@ -0,0 +1,42 @@ +model SecurityAudit { + // ID columns + id String @id @default(uuid()) @db.Uuid + actorUserId String? @map("actor_user_id") @db.Uuid + sessionId String? @map("session_id") @db.Uuid + authenticationProviderRevisionId String? @map("authentication_provider_revision_id") @db.Uuid + 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 + workloadPublisherId String? @map("workload_publisher_id") @db.Uuid + workloadCredentialId String? @map("workload_credential_id") @db.Uuid + targetUserId String? @map("target_user_id") @db.Uuid + githubRepositorySourceId String? @map("github_repository_source_id") @db.Uuid + + // Relations + targetMembership InstallationMembership? @relation("TargetMembership", fields: [targetMembershipId], references: [id], onDelete: SetNull) + invitation MembershipInvitation? @relation(fields: [invitationId], references: [id], onDelete: SetNull) + githubRepositorySource GitHubRepositorySource? @relation(fields: [githubRepositorySourceId], references: [id], onDelete: SetNull) + + // Attribute columns + event String + outcome String @default("succeeded") + context Json @default("{}") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([createdAt]) + @@index([targetMembershipId]) + @@index([invitationId]) + @@index([workspaceId]) + @@index([prototypeId]) + @@index([prototypeVersionId]) + @@index([workloadPublisherId]) + @@index([workloadCredentialId]) + @@index([targetUserId]) + @@index([githubRepositorySourceId]) + @@map("security_audits") +} diff --git a/hub/schema/session.prisma b/hub/schema/session.prisma new file mode 100644 index 00000000..bd8a5d29 --- /dev/null +++ b/hub/schema/session.prisma @@ -0,0 +1,27 @@ +model Session { + // ID columns + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + externalIdentityId String @map("external_identity_id") @db.Uuid + authenticationProviderRevisionId String @map("authentication_provider_revision_id") @db.Uuid + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + externalIdentity ExternalIdentity @relation(fields: [externalIdentityId], references: [id], onDelete: Restrict) + authenticationProviderRevision AuthenticationProviderRevision @relation(fields: [authenticationProviderRevisionId], references: [id], onDelete: Restrict) + originatingAuthorizationAttempts AuthorizationAttempt[] @relation("OriginatingSession") + + // Attribute columns + tokenDigest String @unique @map("token_digest") + signedInAt DateTime @map("signed_in_at") @db.Timestamptz(3) + freshAuthorizedAt DateTime? @map("fresh_authorized_at") @db.Timestamptz(3) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([userId]) + @@index([externalIdentityId]) + @@index([authenticationProviderRevisionId]) + @@map("sessions") +} diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma new file mode 100644 index 00000000..0280e775 --- /dev/null +++ b/hub/schema/user.prisma @@ -0,0 +1,26 @@ +model User { + // ID columns + id String @id @default(uuid()) @db.Uuid + + // Relations + externalIdentities ExternalIdentity[] + memberships InstallationMembership[] + sessions Session[] + createdInvitations MembershipInvitation[] @relation("InvitationCreator") + createdWorkspaces Workspace[] @relation("WorkspaceCreator") + createdPrototypes Prototype[] @relation("PrototypeCreator") + createdVersions PrototypeVersion[] @relation("PrototypeVersionCreator") + workspaceGrants WorkspaceAccessGrant[] + prototypeGrants PrototypeAccessGrant[] + githubSynchronizedAccess GitHubSynchronizedAccess[] + + // Attribute columns + displayName String? @map("display_name") + avatarUrl String? @map("avatar_url") + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@map("users") +} diff --git a/hub/schema/workload-credential.prisma b/hub/schema/workload-credential.prisma new file mode 100644 index 00000000..c043762b --- /dev/null +++ b/hub/schema/workload-credential.prisma @@ -0,0 +1,20 @@ +model WorkloadCredential { + // ID columns + id String @id @default(uuid()) @db.Uuid + workloadPublisherId String @map("workload_publisher_id") @db.Uuid + + // Relations + workloadPublisher WorkloadPublisher @relation(fields: [workloadPublisherId], references: [id], onDelete: Restrict) + + // Attribute columns + tokenDigest String @unique @map("token_digest") + expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_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([workloadPublisherId]) + @@map("workload_credentials") +} diff --git a/hub/schema/workload-publisher.prisma b/hub/schema/workload-publisher.prisma new file mode 100644 index 00000000..3ebebee7 --- /dev/null +++ b/hub/schema/workload-publisher.prisma @@ -0,0 +1,23 @@ +model WorkloadPublisher { + // ID columns + id String @id @default(uuid()) @db.Uuid + prototypeId String @map("prototype_id") @db.Uuid + + // Relations + prototype Prototype @relation(fields: [prototypeId], references: [id], onDelete: Restrict) + credentials WorkloadCredential[] + versions PrototypeVersion[] + publications ArtifactPublication[] + + // Attribute columns + name String + actions String[] + active Boolean @default(true) + + // Timestamp columns + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([prototypeId]) + @@map("workload_publishers") +} 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/script/publish-artifact.mjs b/hub/script/publish-artifact.mjs new file mode 100644 index 00000000..85c64017 --- /dev/null +++ b/hub/script/publish-artifact.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { createHash, randomUUID } from "node:crypto" +import { readFile, stat } from "node:fs/promises" + +function usage() { + console.error( + "Usage: REPLICA_PUBLISH_TOKEN=... node script/publish-artifact.mjs HUB_URL PROTOTYPE_ID ARTIFACT_PATH [current|preview]", + ) +} + +const [, , hubUrl, prototypeId, artifactPath, promoteChannel] = process.argv +const token = process.env.REPLICA_PUBLISH_TOKEN +if ( + !hubUrl || + !prototypeId || + !artifactPath || + !token || + (promoteChannel && !["current", "preview"].includes(promoteChannel)) +) { + usage() + process.exitCode = 2 +} else { + const bytes = await readFile(artifactPath) + const details = await stat(artifactPath) + const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}` + const idempotencyKey = process.env.REPLICA_IDEMPOTENCY_KEY ?? randomUUID() + console.error(`Replica idempotency key: ${idempotencyKey}`) + const response = await fetch( + new URL(`/api/publishing/prototypes/${prototypeId}/versions`, hubUrl), + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "idempotency-key": idempotencyKey, + }, + body: JSON.stringify({ + manifest: { + schemaVersion: 1, + prototypeId, + artifact: { + format: process.env.REPLICA_ARTIFACT_FORMAT ?? "replica.prototype.bundle", + version: process.env.REPLICA_ARTIFACT_VERSION ?? "1", + digest, + sizeBytes: bytes.length, + builtAt: details.mtime.toISOString(), + }, + provenance: { + provider: process.env.REPLICA_PROVENANCE_PROVIDER ?? "local-cli", + ...(process.env.REPLICA_SOURCE_URL + ? { sourceUrl: process.env.REPLICA_SOURCE_URL } + : {}), + ...(process.env.REPLICA_REVISION ? { revision: process.env.REPLICA_REVISION } : {}), + ...(process.env.REPLICA_REF ? { ref: process.env.REPLICA_REF } : {}), + ...(process.env.REPLICA_PIPELINE ? { pipeline: process.env.REPLICA_PIPELINE } : {}), + ...(process.env.REPLICA_RUN_ID ? { runId: process.env.REPLICA_RUN_ID } : {}), + }, + }, + artifactBase64: bytes.toString("base64"), + ...(promoteChannel ? { promoteChannel } : {}), + }), + }, + ) + const result = await response.text() + if (!response.ok) { + console.error(`Publish failed (${response.status}): ${result}`) + process.exitCode = 1 + } else { + process.stdout.write(`${result}\n`) + } +} diff --git a/hub/script/setup-entra-oidc.ps1 b/hub/script/setup-entra-oidc.ps1 new file mode 100644 index 00000000..ff56c25a --- /dev/null +++ b/hub/script/setup-entra-oidc.ps1 @@ -0,0 +1,578 @@ +# This setup script is still being tested. Review the Plan output and intended tenant +# changes before running Apply. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] $HubOrigin, + + [switch] $Plan, + [switch] $Apply, + [switch] $Remove, + [switch] $Preflight, + + [ValidateNotNullOrEmpty()] + [string] $TenantId, + + [ValidateNotNullOrEmpty()] + [string] $ExistingApplicationId, + + [ValidateNotNullOrEmpty()] + [string] $DisplayName = "Replica Hub", + + [ValidateRange(1, 2147483646)] + [int] $ProviderRevision = 1, + + [ValidatePattern("(?-i)^[A-Z][A-Z0-9_]{2,127}$")] + [string] $ClientCredentialReference = "HUB_OIDC_CLIENT_SECRET", + + [ValidateSet("jit-assignment", "invitation-required", IgnoreCase = $false)] + [string] $MembershipPolicy = "jit-assignment", + + [string[]] $UserGroupId = @(), + [string[]] $AdministratorGroupId = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptOwnerTag = "replica-hub:setup-entra-oidc:v1" +$userRoleId = [Guid] "8ee74d7b-bf6c-5fd6-a70a-b3efad17b3d4" +$administratorRoleId = [Guid] "624f20bd-df7b-5ed4-abeb-6f008f99920f" +$callbackPath = "/api/auth/oidc/callback" + +function Get-RequestedAction { + $actions = @($Plan, $Apply, $Remove, $Preflight).Where({ $_ }) + if ($actions.Count -ne 1) { + throw "Choose exactly one of -Plan, -Apply, -Remove, or -Preflight." + } + + if ($Plan) { return "Plan" } + if ($Apply) { return "Apply" } + if ($Remove) { return "Remove" } + return "Preflight" +} + +function Get-NormalizedOrigin { + try { + $origin = [Uri] $HubOrigin + } + catch { + throw "-HubOrigin must be an absolute HTTPS origin." + } + + if (-not $origin.IsAbsoluteUri -or $origin.Scheme -ne "https") { + throw "-HubOrigin must be an absolute HTTPS origin." + } + + if ($origin.AbsolutePath -ne "/" -or $origin.Query -or $origin.Fragment -or $origin.UserInfo) { + throw "-HubOrigin must contain only the HTTPS scheme and authority." + } + + return $origin.GetLeftPart([UriPartial]::Authority) +} + +function Get-AppRoles { + return @( + @{ + Id = $userRoleId + AllowedMemberTypes = @("User") + Description = "Use Replica Hub" + DisplayName = "Hub User" + IsEnabled = $true + Value = "Hub.User" + }, + @{ + Id = $administratorRoleId + AllowedMemberTypes = @("User") + Description = "Administer Replica Hub" + DisplayName = "Hub Administrator" + IsEnabled = $true + Value = "Hub.Administrator" + } + ) +} + +function ConvertTo-WritableOptionalClaim { + param($Claim) + + return @{ + Name = $Claim.Name + Source = $Claim.Source + Essential = $Claim.Essential + AdditionalProperties = @($Claim.AdditionalProperties) + } +} + +function Get-ConvergedOptionalClaims { + param($Application) + + $current = if ($Application) { $Application.OptionalClaims } else { $null } + $currentIdToken = if ($current) { @($current.IdToken) } else { @() } + $currentAccessToken = if ($current) { @($current.AccessToken) } else { @() } + $currentSaml2Token = if ($current) { @($current.Saml2Token) } else { @() } + $idToken = $currentIdToken.Where({ + $_.Name -cne "auth_time" + }).ForEach({ ConvertTo-WritableOptionalClaim -Claim $_ }) + $idToken += @{ + Name = "auth_time" + Source = $null + Essential = $true + AdditionalProperties = @() + } + return @{ + IdToken = @($idToken) + AccessToken = $currentAccessToken.ForEach({ + ConvertTo-WritableOptionalClaim -Claim $_ + }) + Saml2Token = $currentSaml2Token.ForEach({ + ConvertTo-WritableOptionalClaim -Claim $_ + }) + } +} + +function Get-HubConfiguration { + param( + [string] $ResolvedTenantId, + [string] $ApplicationId, + [string] $CallbackUrl, + [string] $Status, + [string[]] $Actions + ) + + [pscustomobject] @{ + Status = $Status + Actions = @($Actions) + HUB_OIDC_ISSUER = if ($ResolvedTenantId) { + "https://login.microsoftonline.com/$ResolvedTenantId/v2.0" + } + else { + $null + } + HUB_OIDC_CLIENT_ID = if ($ApplicationId) { $ApplicationId } else { $ExistingApplicationId } + HUB_OIDC_CALLBACK_URL = $CallbackUrl + HUB_AUTH_PROVIDER = "oidc" + HUB_OIDC_ENABLED = "true" + HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE = $ClientCredentialReference + HUB_OIDC_SCOPES = "openid profile email" + HUB_OIDC_MEMBER_ROLE = "Hub.User" + HUB_OIDC_ADMINISTRATOR_ROLE = "Hub.Administrator" + HUB_OIDC_MEMBERSHIP_POLICY = $MembershipPolicy + HUB_OIDC_PROVIDER_REVISION = $ProviderRevision + } +} + +function Import-GraphModules { + foreach ($module in @( + "Microsoft.Graph.Authentication", + "Microsoft.Graph.Applications", + "Microsoft.Graph.Groups" + )) { + if (-not (Get-Module -ListAvailable -Name $module)) { + throw "Install the Microsoft Graph PowerShell module '$module' before running this action." + } + Import-Module $module -ErrorAction Stop + } +} + +function Connect-Graph { + param([string[]] $Scopes) + + Import-GraphModules + $connect = @{ Scopes = $Scopes; NoWelcome = $true } + if ($TenantId) { + $connect.TenantId = $TenantId + } + Connect-MgGraph @connect | Out-Null + + $context = Get-MgContext + if (-not $context -or -not $context.TenantId) { + throw "Microsoft Graph did not return a tenant context." + } + if ($TenantId -and $context.TenantId -ne $TenantId) { + throw "Connected tenant '$($context.TenantId)' does not match -TenantId '$TenantId'." + } + return $context +} + +function Get-ApplicationByAppId { + param([string] $ApplicationId) + + $escaped = $ApplicationId.Replace("'", "''") + $applications = @(Get-MgApplication -Filter "appId eq '$escaped'" -All) + if ($applications.Count -gt 1) { + throw "Microsoft Graph returned more than one application for app ID '$ApplicationId'." + } + return $applications | Select-Object -First 1 +} + +function Get-OwnedApplication { + $escaped = $DisplayName.Replace("'", "''") + $applications = @(Get-MgApplication -Filter "displayName eq '$escaped'" -All) + $owned = @($applications.Where({ @($_.Tags) -contains $scriptOwnerTag })) + if ($owned.Count -gt 1) { + throw "More than one script-owned application is named '$DisplayName'. Use -ExistingApplicationId." + } + if ($owned.Count -eq 1) { + return $owned[0] + } + if ($applications.Count -gt 0) { + throw "An application named '$DisplayName' exists but is not owned by this script. Use -ExistingApplicationId to manage it without granting removal ownership." + } + return $null +} + +function Resolve-Application { + if ($ExistingApplicationId) { + $application = Get-ApplicationByAppId -ApplicationId $ExistingApplicationId + if (-not $application) { + throw "Application '$ExistingApplicationId' was not found in the connected tenant." + } + return $application + } + return Get-OwnedApplication +} + +function Assert-ManagedRoleIdsAvailable { + param($Application) + + foreach ($role in @($Application.AppRoles)) { + if (($role.Value -ceq "Hub.User" -and $role.Id -ne $userRoleId) -or + ($role.Value -ceq "Hub.Administrator" -and $role.Id -ne $administratorRoleId)) { + throw "The application has a conflicting Replica Hub app-role ID or value." + } + } +} + +function Get-ConvergedRoles { + param($Application) + + Assert-ManagedRoleIdsAvailable -Application $Application + $otherRoles = @($Application.AppRoles).Where({ + $_.Id -ne $userRoleId -and + $_.Id -ne $administratorRoleId -and + $_.Value -cne "Hub.User" -and + $_.Value -cne "Hub.Administrator" + }).ForEach({ + @{ + Id = $_.Id + AllowedMemberTypes = @($_.AllowedMemberTypes) + Description = $_.Description + DisplayName = $_.DisplayName + IsEnabled = $_.IsEnabled + Value = $_.Value + } + }) + return @($otherRoles) + @(Get-AppRoles) +} + +function Test-ApplicationDrift { + param( + $Application, + [object[]] $DesiredRoles, + [string[]] $DesiredRedirectUris + ) + + if ($Application.SignInAudience -ne "AzureADMyOrg") { return $true } + $authTimeClaims = if ($Application.OptionalClaims) { + @($Application.OptionalClaims.IdToken).Where({ + $_.Name -ceq "auth_time" + }) + } + else { @() } + if ($authTimeClaims.Count -ne 1 -or -not $authTimeClaims[0].Essential) { return $true } + $actualRedirectUris = @(@($Application.Web.RedirectUris) | Sort-Object -CaseSensitive) + $expectedRedirectUris = @(@($DesiredRedirectUris) | Sort-Object -CaseSensitive) + if ($actualRedirectUris.Count -ne $expectedRedirectUris.Count) { return $true } + for ($index = 0; $index -lt $actualRedirectUris.Count; $index += 1) { + if ($actualRedirectUris[$index] -cne $expectedRedirectUris[$index]) { return $true } + } + + foreach ($desired in Get-AppRoles) { + $actual = @($Application.AppRoles).Where({ $_.Value -ceq $desired.Value }) + if ($actual.Count -ne 1) { return $true } + $actualRole = $actual[0] + if ($actualRole.Id -ne $desired.Id -or + $actualRole.DisplayName -ne $desired.DisplayName -or + $actualRole.Description -ne $desired.Description -or + -not $actualRole.IsEnabled -or + @($actualRole.AllowedMemberTypes).Count -ne 1 -or + $actualRole.AllowedMemberTypes[0] -ne "User") { + return $true + } + } + + if (@($Application.AppRoles).Count -ne $DesiredRoles.Count) { return $true } + return $false +} + +function Get-ConvergedRedirectUris { + param( + $Application, + [string] $CallbackUrl + ) + + if (@($Application.Tags) -contains $scriptOwnerTag) { + return @($CallbackUrl) + } + $redirectUris = @($Application.Web.RedirectUris) + @($CallbackUrl) + return @($redirectUris | Sort-Object -CaseSensitive -Unique) +} + +function Get-ServicePrincipal { + param([string] $ApplicationId) + + $escaped = $ApplicationId.Replace("'", "''") + $principals = @(Get-MgServicePrincipal -Filter "appId eq '$escaped'" -All) + if ($principals.Count -gt 1) { + throw "Microsoft Graph returned more than one service principal for app ID '$ApplicationId'." + } + return $principals | Select-Object -First 1 +} + +function Assert-GroupsExist { + param([string[]] $GroupIds) + + foreach ($groupId in $GroupIds | Sort-Object -Unique) { + Get-MgGroup -GroupId $groupId -Property "id,displayName" | Out-Null + } +} + +function Get-DesiredAssignments { + param( + [string[]] $UserGroups, + [string[]] $AdministratorGroups + ) + + $desired = @() + foreach ($groupId in $UserGroups | Sort-Object -Unique) { + $desired += [pscustomobject] @{ GroupId = $groupId; RoleId = $userRoleId; Role = "Hub.User" } + } + foreach ($groupId in $AdministratorGroups | Sort-Object -Unique) { + $desired += [pscustomobject] @{ GroupId = $groupId; RoleId = $administratorRoleId; Role = "Hub.Administrator" } + } + + return $desired +} + +function Get-AssignmentChanges { + param( + $ServicePrincipal, + [string[]] $UserGroups, + [string[]] $AdministratorGroups + ) + + $desired = @(Get-DesiredAssignments -UserGroups $UserGroups ` + -AdministratorGroups $AdministratorGroups) + $existing = @(Get-MgServicePrincipalAppRoleAssignedTo ` + -ServicePrincipalId $ServicePrincipal.Id -All) + $managed = @($existing).Where({ + $_.PrincipalType -eq "Group" -and + $_.AppRoleId -in @($userRoleId, $administratorRoleId) + }) + $missing = @($desired).Where({ + $candidate = $_ + @($managed).Where({ + $_.PrincipalId -eq $candidate.GroupId -and $_.AppRoleId -eq $candidate.RoleId + }).Count -eq 0 + }) + $obsolete = @($managed).Where({ + $candidate = $_ + @($desired).Where({ + $_.GroupId -eq $candidate.PrincipalId -and $_.RoleId -eq $candidate.AppRoleId + }).Count -eq 0 + }) + return [pscustomobject] @{ + Missing = $missing + Obsolete = $obsolete + } +} + +function Invoke-Preflight { + param([string] $CallbackUrl) + + $actions = @( + "Install Microsoft.Graph.Authentication, Microsoft.Graph.Applications, and Microsoft.Graph.Groups.", + "Plan requires Application.Read.All and Group.Read.All.", + "Apply and Remove require Application.ReadWrite.All, AppRoleAssignment.ReadWrite.All, and Group.Read.All.", + "Tenant administrator approval must be completed through the tenant's normal consent process.", + "Apply requires at least one -UserGroupId and one -AdministratorGroupId." + ) + return Get-HubConfiguration -ResolvedTenantId $TenantId -ApplicationId $ExistingApplicationId ` + -CallbackUrl $CallbackUrl -Status "Preflight" -Actions $actions +} + +function Invoke-Plan { + param([string] $CallbackUrl) + + $context = Connect-Graph -Scopes @("Application.Read.All", "Group.Read.All") + Assert-GroupsExist -GroupIds @($UserGroupId + $AdministratorGroupId) + $application = Resolve-Application + $actions = @() + + if (-not $application) { + $actions += "Create single-tenant application '$DisplayName'." + $actions += "Create its service principal and require assignment." + foreach ($groupId in $UserGroupId | Sort-Object -Unique) { + $actions += "Assign group '$groupId' to Hub.User." + } + foreach ($groupId in $AdministratorGroupId | Sort-Object -Unique) { + $actions += "Assign group '$groupId' to Hub.Administrator." + } + } + else { + $roles = Get-ConvergedRoles -Application $application + $redirectUris = Get-ConvergedRedirectUris -Application $application -CallbackUrl $CallbackUrl + if (Test-ApplicationDrift -Application $application ` + -DesiredRoles $roles -DesiredRedirectUris $redirectUris) { + $actions += "Converge the callback, audience, and Replica Hub app roles." + } + $servicePrincipal = Get-ServicePrincipal -ApplicationId $application.AppId + if (-not $servicePrincipal) { + $actions += "Create the service principal and require assignment." + } + elseif (-not $servicePrincipal.AppRoleAssignmentRequired) { + $actions += "Require assignment on the service principal." + } + $changes = if ($servicePrincipal) { + Get-AssignmentChanges -ServicePrincipal $servicePrincipal ` + -UserGroups $UserGroupId -AdministratorGroups $AdministratorGroupId + } + else { + [pscustomobject] @{ + Missing = @(Get-DesiredAssignments -UserGroups $UserGroupId ` + -AdministratorGroups $AdministratorGroupId) + Obsolete = @() + } + } + foreach ($assignment in $changes.Missing) { + $actions += "Assign group '$($assignment.GroupId)' to $($assignment.Role)." + } + foreach ($assignment in $changes.Obsolete) { + $role = if ($assignment.AppRoleId -eq $userRoleId) { "Hub.User" } ` + else { "Hub.Administrator" } + $actions += "Remove group '$($assignment.PrincipalId)' from $role." + } + } + + if ($UserGroupId.Count -eq 0 -or $AdministratorGroupId.Count -eq 0) { + $actions += "Provide at least one user group and one administrator group before Apply." + } + if ($actions.Count -eq 0) { + $actions += "No changes are required." + } + + return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` + -ApplicationId $(if ($application) { $application.AppId } else { $null }) ` + -CallbackUrl $CallbackUrl -Status "Plan" -Actions $actions +} + +function Invoke-Apply { + param([string] $CallbackUrl) + + if ($UserGroupId.Count -eq 0 -or $AdministratorGroupId.Count -eq 0) { + throw "-Apply requires at least one -UserGroupId and one -AdministratorGroupId." + } + + $context = Connect-Graph -Scopes @( + "Application.ReadWrite.All", + "AppRoleAssignment.ReadWrite.All", + "Group.Read.All" + ) + Assert-GroupsExist -GroupIds @($UserGroupId + $AdministratorGroupId) + $application = Resolve-Application + + if (-not $application) { + $application = New-MgApplication -DisplayName $DisplayName ` + -SignInAudience "AzureADMyOrg" ` + -Web @{ RedirectUris = @($CallbackUrl) } ` + -OptionalClaims (Get-ConvergedOptionalClaims -Application $null) ` + -AppRoles (Get-AppRoles) ` + -Tags @($scriptOwnerTag) + } + else { + $roles = Get-ConvergedRoles -Application $application + $optionalClaims = Get-ConvergedOptionalClaims -Application $application + $redirectUris = Get-ConvergedRedirectUris -Application $application -CallbackUrl $CallbackUrl + if (Test-ApplicationDrift -Application $application ` + -DesiredRoles $roles -DesiredRedirectUris $redirectUris) { + Update-MgApplication -ApplicationId $application.Id ` + -SignInAudience "AzureADMyOrg" ` + -Web @{ RedirectUris = $redirectUris } ` + -OptionalClaims $optionalClaims ` + -AppRoles $roles | Out-Null + $application = Get-ApplicationByAppId -ApplicationId $application.AppId + } + } + + $servicePrincipal = Get-ServicePrincipal -ApplicationId $application.AppId + if (-not $servicePrincipal) { + $servicePrincipal = New-MgServicePrincipal -AppId $application.AppId -Tags @($scriptOwnerTag) + } + if (-not $servicePrincipal.AppRoleAssignmentRequired) { + Update-MgServicePrincipal -ServicePrincipalId $servicePrincipal.Id ` + -AppRoleAssignmentRequired:$true | Out-Null + $servicePrincipal.AppRoleAssignmentRequired = $true + } + + $changes = Get-AssignmentChanges -ServicePrincipal $servicePrincipal ` + -UserGroups $UserGroupId -AdministratorGroups $AdministratorGroupId + foreach ($assignment in $changes.Missing) { + New-MgGroupAppRoleAssignment -GroupId $assignment.GroupId ` + -PrincipalId $assignment.GroupId ` + -ResourceId $servicePrincipal.Id ` + -AppRoleId $assignment.RoleId | Out-Null + } + foreach ($assignment in $changes.Obsolete) { + Remove-MgGroupAppRoleAssignment -GroupId $assignment.PrincipalId ` + -AppRoleAssignmentId $assignment.Id | Out-Null + } + + return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` + -ApplicationId $application.AppId -CallbackUrl $CallbackUrl ` + -Status "Applied" -Actions @("Configuration converged.") +} + +function Invoke-Remove { + param([string] $CallbackUrl) + + $context = Connect-Graph -Scopes @( + "Application.ReadWrite.All", + "AppRoleAssignment.ReadWrite.All", + "Group.Read.All" + ) + $application = Resolve-Application + if (-not $application) { + return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` + -ApplicationId $null -CallbackUrl $CallbackUrl ` + -Status "Absent" -Actions @("No script-owned application exists.") + } + + if (@($application.Tags) -notcontains $scriptOwnerTag) { + throw "Refusing to remove application '$($application.AppId)' because it is not owned by this script." + } + + $servicePrincipal = Get-ServicePrincipal -ApplicationId $application.AppId + if ($servicePrincipal) { + if (@($servicePrincipal.Tags) -notcontains $scriptOwnerTag) { + throw "Refusing to remove service principal '$($servicePrincipal.Id)' because it is not owned by this script." + } + Remove-MgServicePrincipal -ServicePrincipalId $servicePrincipal.Id + } + Remove-MgApplication -ApplicationId $application.Id + + return Get-HubConfiguration -ResolvedTenantId $context.TenantId ` + -ApplicationId $application.AppId -CallbackUrl $CallbackUrl ` + -Status "Removed" -Actions @("Removed script-owned application and service principal.") +} + +$action = Get-RequestedAction +$normalizedOrigin = Get-NormalizedOrigin +$callbackUrl = "$normalizedOrigin$callbackPath" + +switch ($action) { + "Preflight" { Invoke-Preflight -CallbackUrl $callbackUrl } + "Plan" { Invoke-Plan -CallbackUrl $callbackUrl } + "Apply" { Invoke-Apply -CallbackUrl $callbackUrl } + "Remove" { Invoke-Remove -CallbackUrl $callbackUrl } +} diff --git a/hub/script/tests/publish-artifact.test.mjs b/hub/script/tests/publish-artifact.test.mjs new file mode 100644 index 00000000..42221cb4 --- /dev/null +++ b/hub/script/tests/publish-artifact.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { createServer } from "node:http" +import { once } from "node:events" +import { fileURLToPath } from "node:url" +import { it } from "node:test" + +const script = fileURLToPath(new URL("../publish-artifact.mjs", import.meta.url)) +const artifact = fileURLToPath(new URL("../../package.json", import.meta.url)) +const prototypeId = "50d2e31f-8aa1-4b67-8fb4-67f045b89a0d" + +function runPublisher(hubUrl, idempotencyKey) { + const child = spawn(process.execPath, [script, hubUrl, prototypeId, artifact], { + env: { + ...process.env, + REPLICA_PUBLISH_TOKEN: "test-token", + ...(idempotencyKey ? { REPLICA_IDEMPOTENCY_KEY: idempotencyKey } : {}), + }, + }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8").on("data", (chunk) => { + stdout += chunk + }) + child.stderr.setEncoding("utf8").on("data", (chunk) => { + stderr += chunk + }) + return once(child, "close").then(([code, signal]) => ({ code, signal, stdout, stderr })) +} + +it("prints the idempotency key before a failed publish so it can be reused", async (t) => { + const receivedKeys = [] + let requestCount = 0 + const server = createServer((request, response) => { + receivedKeys.push(request.headers["idempotency-key"]) + request.resume() + request.on("end", () => { + requestCount += 1 + if (requestCount === 1) { + response.writeHead(503, { "content-type": "application/json" }) + response.end('{"error":"response lost"}') + } else { + response.writeHead(201, { "content-type": "application/json" }) + response.end('{"replayed":true}') + } + }) + }) + server.listen(0, "127.0.0.1") + await once(server, "listening") + t.after(() => server.close()) + const address = server.address() + assert.ok(address && typeof address === "object") + const hubUrl = `http://127.0.0.1:${address.port}` + + const failed = await runPublisher(hubUrl) + const generatedKey = failed.stderr.match( + /Replica idempotency key: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i, + )?.[1] + assert.equal(failed.code, 1) + assert.equal(failed.signal, null) + assert.ok(generatedKey) + assert.match(failed.stderr, /Publish failed \(503\)/) + + const retried = await runPublisher(hubUrl, generatedKey) + assert.equal(retried.code, 0) + assert.equal(retried.signal, null) + assert.equal(retried.stderr, `Replica idempotency key: ${generatedKey}\n`) + assert.equal(retried.stdout, '{"replayed":true}\n') + assert.deepEqual(receivedKeys, [generatedKey, generatedKey]) +}) diff --git a/hub/script/tests/setup-entra-oidc.test.mjs b/hub/script/tests/setup-entra-oidc.test.mjs new file mode 100644 index 00000000..5d549e20 --- /dev/null +++ b/hub/script/tests/setup-entra-oidc.test.mjs @@ -0,0 +1,303 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { describe, it } from "node:test" + +const script = await readFile(new URL("../setup-entra-oidc.ps1", import.meta.url), "utf8") + +function functionBody(name) { + const start = script.indexOf(`function ${name} {`) + assert.notEqual(start, -1, `Expected ${name} to exist`) + + const openingBrace = script.indexOf("{", start) + let depth = 0 + for (let index = openingBrace; index < script.length; index += 1) { + if (script[index] === "{") depth += 1 + if (script[index] === "}") depth -= 1 + if (depth === 0) return script.slice(openingBrace + 1, index) + } + assert.fail(`Expected ${name} to have a balanced body`) +} + +const mutatingCommands = + /\b(?:New|Update|Remove)-Mg(?:Application|ServicePrincipal|GroupAppRoleAssignment)\b/ + +describe("setup-entra-oidc.ps1", () => { + it("requires exactly one requested action", () => { + const action = functionBody("Get-RequestedAction") + + assert.match(action, /@\(\$Plan, \$Apply, \$Remove, \$Preflight\)\.Where/) + assert.match(action, /if \(\$actions\.Count -ne 1\)/) + assert.match(action, /Choose exactly one of -Plan, -Apply, -Remove, or -Preflight\./) + }) + + it("keeps preflight and plan read-only", () => { + const preflight = functionBody("Invoke-Preflight") + const plan = functionBody("Invoke-Plan") + + assert.doesNotMatch(preflight, /\bConnect-MgGraph\b|\b\w+-Mg\w+\b/) + assert.doesNotMatch(plan, mutatingCommands) + assert.doesNotMatch(plan, /\b(?:New|Update|Remove)-Mg\w+\b/) + assert.match(plan, /Application\.Read\.All/) + assert.match(plan, /Group\.Read\.All/) + }) + + it("converges application drift and missing assignments", () => { + const apply = functionBody("Invoke-Apply") + const drift = functionBody("Test-ApplicationDrift") + const assignments = functionBody("Get-AssignmentChanges") + + assert.match(apply, /if \(-not \$application\)[\s\S]*New-MgApplication/) + assert.match(apply, /if \(Test-ApplicationDrift[\s\S]*Update-MgApplication/) + assert.match(apply, /if \(-not \$servicePrincipal\)[\s\S]*New-MgServicePrincipal/) + assert.match( + apply, + /if \(-not \$servicePrincipal\.AppRoleAssignmentRequired\)[\s\S]*Update-MgServicePrincipal/, + ) + assert.match( + assignments, + /\$_.PrincipalId -eq \$candidate\.GroupId -and \$_.AppRoleId -eq \$candidate\.RoleId/, + ) + assert.match( + apply, + /foreach \(\$assignment in \$changes\.Missing\)[\s\S]*New-MgGroupAppRoleAssignment/, + ) + assert.match(drift, /AzureADMyOrg/) + assert.match(drift, /Sort-Object -CaseSensitive/) + assert.match(drift, /\$actualRedirectUris\.Count -ne \$expectedRedirectUris\.Count/) + assert.match(drift, /\$actualRedirectUris\[\$index\] -cne \$expectedRedirectUris\[\$index\]/) + assert.match(apply, /Assert-GroupsExist[\s\S]*Resolve-Application/) + assert.match( + apply, + /Get-AssignmentChanges[\s\S]*foreach \(\$assignment in \$changes\.Missing\)/, + ) + }) + + it("keeps app roles writable and projects preserved roles to writable fields", () => { + const desiredRoles = functionBody("Get-AppRoles") + const convergedRoles = functionBody("Get-ConvergedRoles") + + assert.doesNotMatch(desiredRoles, /\bOrigin\b/) + assert.match(convergedRoles, /Id = \$_.Id/) + assert.match(convergedRoles, /AllowedMemberTypes = @\(\$_.AllowedMemberTypes\)/) + assert.match(convergedRoles, /Description = \$_.Description/) + assert.match(convergedRoles, /DisplayName = \$_.DisplayName/) + assert.match(convergedRoles, /IsEnabled = \$_.IsEnabled/) + assert.match(convergedRoles, /Value = \$_.Value/) + assert.doesNotMatch(convergedRoles, /\bOrigin\s*=/) + }) + + it("sends an essential auth_time ID-token claim when creating or updating the application", () => { + const apply = functionBody("Invoke-Apply") + const optionalClaims = functionBody("Get-ConvergedOptionalClaims") + + assert.match(optionalClaims, /Name = "auth_time"/) + assert.match(optionalClaims, /Essential = \$true/) + assert.match( + apply, + /New-MgApplication[\s\S]*-OptionalClaims \(Get-ConvergedOptionalClaims -Application \$null\)/, + ) + assert.match( + apply, + /\$optionalClaims = Get-ConvergedOptionalClaims -Application \$application[\s\S]*Update-MgApplication[\s\S]*-OptionalClaims \$optionalClaims/, + ) + }) + + it("preserves unrelated ID, access, and SAML optional claims through writable projections", () => { + const projection = functionBody("ConvertTo-WritableOptionalClaim") + const optionalClaims = functionBody("Get-ConvergedOptionalClaims") + + assert.match(projection, /Name = \$Claim\.Name/) + assert.match(projection, /Source = \$Claim\.Source/) + assert.match(projection, /Essential = \$Claim\.Essential/) + assert.match(projection, /AdditionalProperties = @\(\$Claim\.AdditionalProperties\)/) + assert.match( + optionalClaims, + /\$currentIdToken\.Where\(\{[\s\S]*\$_.Name -cne "auth_time"[\s\S]*\}\)\.ForEach\(\{ ConvertTo-WritableOptionalClaim -Claim \$_ \}\)/, + ) + assert.match(optionalClaims, /IdToken = @\(\$idToken\)/) + assert.match( + optionalClaims, + /AccessToken = \$currentAccessToken\.ForEach\(\{[\s\S]*ConvertTo-WritableOptionalClaim -Claim \$_/, + ) + assert.match( + optionalClaims, + /Saml2Token = \$currentSaml2Token\.ForEach\(\{[\s\S]*ConvertTo-WritableOptionalClaim -Claim \$_/, + ) + }) + + it("treats missing, duplicate, or nonessential auth_time claims as application drift", () => { + const drift = functionBody("Test-ApplicationDrift") + + assert.match(drift, /\$authTimeClaims = if \(\$Application\.OptionalClaims\)/) + assert.match(drift, /\$_.Name -ceq "auth_time"/) + assert.match(drift, /\$authTimeClaims\.Count -ne 1 -or -not \$authTimeClaims\[0\]\.Essential/) + }) + + it("treats empty and case-changed redirect URI lists as drift", () => { + const drift = functionBody("Test-ApplicationDrift") + + assert.match(drift, /@\(@\(\$Application\.Web\.RedirectUris\) \| Sort-Object -CaseSensitive\)/) + assert.match(drift, /@\(@\(\$DesiredRedirectUris\) \| Sort-Object -CaseSensitive\)/) + assert.match(drift, /\$actualRedirectUris\.Count -ne \$expectedRedirectUris\.Count/) + assert.match(drift, /-cne/) + assert.doesNotMatch(drift, /\bCompare-Object\b/) + }) + + it("keeps client credential references case-sensitive", () => { + assert.match( + script, + /\[ValidatePattern\("\(\?-i\)\^\[A-Z\]\[A-Z0-9_\]\{2,127\}\$"\)\]\s*\[string\] \$ClientCredentialReference/, + ) + }) + + it("keeps membership policy casing and provider revision bounds aligned with Hub", () => { + assert.match(script, /\[ValidateRange\(1, 2147483646\)\]\s*\[int\] \$ProviderRevision/) + assert.match( + script, + /\[ValidateSet\("jit-assignment", "invitation-required", IgnoreCase = \$false\)\]\s*\[string\] \$MembershipPolicy/, + ) + }) + + it("matches and preserves app-role values case-sensitively", () => { + const available = functionBody("Assert-ManagedRoleIdsAvailable") + const converged = functionBody("Get-ConvergedRoles") + const drift = functionBody("Test-ApplicationDrift") + + assert.match(available, /\$role\.Value -ceq "Hub\.User"/) + assert.match(available, /\$role\.Value -ceq "Hub\.Administrator"/) + assert.match(converged, /\$_.Value -cne "Hub\.User"/) + assert.match(converged, /\$_.Value -cne "Hub\.Administrator"/) + assert.match(drift, /\$_.Value -ceq \$desired\.Value/) + }) + + it("deduplicates redirect URIs without collapsing case variants", () => { + const redirects = functionBody("Get-ConvergedRedirectUris") + + assert.match(redirects, /Sort-Object -CaseSensitive -Unique/) + assert.doesNotMatch(redirects, /Sort-Object -Unique/) + }) + + it("limits obsolete assignments to managed group roles and preserves unrelated assignments", () => { + const assignments = functionBody("Get-AssignmentChanges") + + assert.match(assignments, /Get-MgServicePrincipalAppRoleAssignedTo/) + assert.match(assignments, /\$_.PrincipalType -eq "Group"/) + assert.match(assignments, /\$_.AppRoleId -in @\(\$userRoleId, \$administratorRoleId\)/) + assert.match(assignments, /\$obsolete = @\(\$managed\)\.Where/) + assert.doesNotMatch(assignments, /\$obsolete = @\(\$existing\)\.Where/) + }) + + it("reports both missing and obsolete managed assignments in the plan", () => { + const plan = functionBody("Invoke-Plan") + + assert.match( + plan, + /foreach \(\$assignment in \$changes\.Missing\)[\s\S]*Assign group '\$\(\$assignment\.GroupId\)'/, + ) + assert.match( + plan, + /foreach \(\$assignment in \$changes\.Obsolete\)[\s\S]*Remove group '\$\(\$assignment\.PrincipalId\)'/, + ) + assert.match(plan, /if \(\$assignment\.AppRoleId -eq \$userRoleId\) \{ "Hub\.User" \}/) + assert.match(plan, /else \{ "Hub\.Administrator" \}/) + }) + + it("reports every desired assignment when the application exists without a service principal", () => { + const plan = functionBody("Invoke-Plan") + + assert.match( + plan, + /else \{\s*\[pscustomobject\] @\{\s*Missing = @\(Get-DesiredAssignments -UserGroups \$UserGroupId/, + ) + assert.match(plan, /-AdministratorGroups \$AdministratorGroupId\)\s*Obsolete = @\(\)/) + }) + + it("removes obsolete managed group assignments during apply", () => { + const apply = functionBody("Invoke-Apply") + + assert.match( + apply, + /foreach \(\$assignment in \$changes\.Obsolete\)[\s\S]*Remove-MgGroupAppRoleAssignment/, + ) + assert.match(apply, /-GroupId \$assignment\.PrincipalId/) + assert.match(apply, /-AppRoleAssignmentId \$assignment\.Id/) + }) + + it("fails closed before removing unowned resources", () => { + const remove = functionBody("Invoke-Remove") + const ownershipGuard = remove.indexOf("if (@($application.Tags) -notcontains $scriptOwnerTag)") + const principalGuard = remove.indexOf( + "if (@($servicePrincipal.Tags) -notcontains $scriptOwnerTag)", + ) + const removePrincipal = remove.indexOf("Remove-MgServicePrincipal") + const removeApplication = remove.indexOf("Remove-MgApplication") + + assert.ok(ownershipGuard >= 0) + assert.ok(principalGuard > ownershipGuard) + assert.ok(removePrincipal > principalGuard) + assert.ok(removeApplication > removePrincipal) + assert.match(remove, /Refusing to remove application/) + assert.match(remove, /Refusing to remove service principal/) + }) + + it("makes removal idempotent when no script-owned application exists", () => { + const remove = functionBody("Invoke-Remove") + const absentGuard = remove.indexOf("if (-not $application)") + const absentResult = remove.indexOf('-Status "Absent"', absentGuard) + const firstMutation = Math.min( + ...["Remove-MgServicePrincipal", "Remove-MgApplication"] + .map((command) => remove.indexOf(command)) + .filter((index) => index >= 0), + ) + + assert.ok(absentGuard >= 0) + assert.ok(absentResult > absentGuard) + assert.ok(firstMutation > absentResult) + assert.match(remove, /No script-owned application exists\./) + }) + + it("uses the exact callback, roles, and nonsecret output contract", () => { + const output = functionBody("Get-HubConfiguration") + + assert.match(script, /\$callbackPath = "\/api\/auth\/oidc\/callback"/) + assert.match(script, /Value = "Hub\.User"/) + assert.match(script, /Value = "Hub\.Administrator"/) + assert.match(script, /-AppRoleAssignmentRequired:\$true/) + assert.match(output, /HUB_OIDC_ISSUER/) + assert.match(output, /HUB_OIDC_CLIENT_ID/) + assert.match(output, /HUB_OIDC_CALLBACK_URL/) + assert.match(output, /HUB_AUTH_PROVIDER/) + assert.match(output, /HUB_OIDC_ENABLED/) + assert.match(output, /HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE/) + assert.match(output, /HUB_OIDC_SCOPES/) + assert.match(output, /HUB_OIDC_MEMBER_ROLE/) + assert.match(output, /HUB_OIDC_MEMBERSHIP_POLICY/) + assert.match(output, /HUB_OIDC_PROVIDER_REVISION/) + assert.doesNotMatch(output, /HUB_OIDC_CLIENT_SECRET|ClientSecret|CredentialValue/) + assert.doesNotMatch(script, /\bWrite-(?:Host|Output|Information|Verbose|Debug)\b/) + assert.doesNotMatch( + script, + /\b(?:Add-MgApplicationPassword|New-MgApplicationPassword|client_?secret|password credential)\b/i, + ) + }) + + it("does not claim removal ownership for an existing application", () => { + const resolve = functionBody("Resolve-Application") + const apply = functionBody("Invoke-Apply") + const redirects = functionBody("Get-ConvergedRedirectUris") + + assert.match(resolve, /if \(\$ExistingApplicationId\)[\s\S]*Get-ApplicationByAppId/) + const applicationUpdate = apply.match(/Update-MgApplication[\s\S]*?\| Out-Null/)?.[0] + assert.ok(applicationUpdate) + assert.doesNotMatch(applicationUpdate, /-Tags/) + assert.match(apply, /New-MgApplication[\s\S]*-Tags @\(\$scriptOwnerTag\)/) + assert.match( + redirects, + /if \(@\(\$Application\.Tags\) -contains \$scriptOwnerTag\)[\s\S]*return @\(\$CallbackUrl\)/, + ) + assert.match( + redirects, + /\$redirectUris = @\(\$Application\.Web\.RedirectUris\) \+ @\(\$CallbackUrl\)/, + ) + }) +}) diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index a3954deb..14ed4740 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -1,19 +1,54 @@ import express from "express" import type { Logger } from "pino" +import { maximumPublishingJsonBytes } from "../domains/artifact-publishing/create-service/constants.ts" +import { createRateLimit } from "../integrations/express/create-rate-limit/index.ts" import { handleError } from "../integrations/express/handle-error/index.ts" +import { preparePublishingRequest } from "../integrations/express/prepare-publishing-request/index.ts" import { respondNotFound } from "../integrations/express/respond-not-found/index.ts" import { createRequestLogger } from "../integrations/pino/create-request-logger/index.ts" +import { omitRequestLogPayload } from "../integrations/pino/omit-request-log-payload/index.ts" import { routes } from "../routes/index.ts" +import type { AuthenticationRoutes } from "../routes/types.ts" /** Give every endpoint the same request logging and safe error responses. */ -export function createApp({ logger }: { logger?: Logger } = {}) { +export function createApp({ + authentication, + logger, +}: { authentication?: AuthenticationRoutes; logger?: Logger } = {}) { const app = express() 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", + "/api/github/webhooks", + "/api/workspaces", + "/api/prototypes", + "/api/publishing", + ], + omitRequestLogPayload, + ) + app.use("/api/github/webhooks", express.raw({ type: "application/json", limit: "1mb" })) + app.use("/api/publishing", preparePublishingRequest) + app.use( + "/api/publishing", + createRateLimit({ + limit: 60, + windowMilliseconds: 5 * 60_000, + message: "Too many publishing requests.", + }), + ) + app.use( + "/api/publishing/prototypes/:prototypeId/versions", + express.json({ limit: maximumPublishingJsonBytes }), + ) app.use(express.json()) - routes(app) + routes(app, authentication) app.use(respondNotFound) app.use(handleError) diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts new file mode 100644 index 00000000..55220258 --- /dev/null +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -0,0 +1,595 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createAdministrationService } from "#server/domains/administration/create-service" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +const entraIssuer = "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0" + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { prisma, store: createIdentityStore(prisma) } +} + +async function createAdministrator( + store: IdentityStore, + subject: string, + token: string, + options: { + role?: "member" | "administrator" + source?: "manual" | "synchronized" + } = {}, +) { + const authorizedAt = new Date("2026-10-27T18:00:00.000Z") + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `Administrator ${subject}`, + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `administrator-${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: options.role ?? "administrator", + source: options.source ?? "manual", + suspendedAt: null, + removedAt: null, + }) + const session = await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }) + return { membership, session, user } + }) +} + +describe("createAdministrationService() database behavior", () => { + it("detects an existing OIDC assignment by its normalized directory object ID", async () => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const directoryObjectId = "a0e1d2c3-b4a5-4678-9abc-def012345678" + await store.lock(async (records) => { + const user = await records.createUser({ + displayName: "OIDC member", + avatarUrl: null, + }) + await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: entraIssuer, + subject: "provider-subject", + directoryObjectId, + username: "member@example.test", + displayName: "OIDC member", + avatarUrl: null, + }) + await records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source: "oidc-assignment", + }) + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.invite("actor-token", { + provider: "oidc", + issuer: "https://login.microsoftonline.com/A0B1C2D3-E4F5-4A67-8B9C-0D1E2F3A4B5C/v2.0/", + subject: directoryObjectId.toUpperCase(), + role: "member", + }), + ).rejects.toMatchObject({ + status: 409, + code: "identity_already_assigned", + }) + expect(await store.read.invitations()).toEqual([]) + }) + + it("normalizes an Entra invitation before persistence and duplicate lookup", async () => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + const directoryObjectId = "b1e2d3c4-a5b6-4789-abcd-ef0123456789" + + await expect( + service.invite("actor-token", { + provider: "oidc", + issuer: "https://login.microsoftonline.com/A0B1C2D3-E4F5-4A67-8B9C-0D1E2F3A4B5C/v2.0/", + subject: directoryObjectId.toUpperCase(), + role: "member", + }), + ).resolves.toMatchObject({ + issuer: entraIssuer, + subject: directoryObjectId, + }) + + await expect( + service.invite("actor-token", { + provider: "oidc", + issuer: entraIssuer, + subject: directoryObjectId, + role: "member", + }), + ).rejects.toMatchObject({ + status: 409, + code: "invitation_exists", + }) + expect(await store.read.invitations()).toEqual([ + expect.objectContaining({ + issuer: entraIssuer, + subject: directoryObjectId, + }), + ]) + }) + + it("rejects an OIDC invitation for an unsupported Entra issuer", async () => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.invite("actor-token", { + provider: "oidc", + issuer: "https://login.example.test/tenant/v2.0", + subject: "b1e2d3c4-a5b6-4789-abcd-ef0123456789", + role: "member", + }), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_invitation", + }) + expect(await store.read.invitations()).toEqual([]) + }) + + it("serializes overlapping suspensions so one active administrator and its session remain", async () => { + const { store } = await database() + const first = await createAdministrator(store, "41", "first-token") + const second = await createAdministrator(store, "42", "second-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + const results = await Promise.allSettled([ + service.changeMembership("first-token", second.membership.id, { + status: "suspended", + }), + service.changeMembership("second-token", first.membership.id, { + status: "suspended", + }), + ]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + expect(await store.read.activeAdministratorCount()).toBe(1) + + const memberships = await store.read.memberships() + const active = memberships.find(({ status }) => status === "active") + const suspended = memberships.find(({ status }) => status === "suspended") + expect(active).toBeDefined() + expect(suspended).toBeDefined() + const activeToken = active?.id === first.membership.id ? "first-token" : "second-token" + const suspendedToken = suspended?.id === first.membership.id ? "first-token" : "second-token" + expect(await store.read.session(digestSecret(activeToken))).toBeTruthy() + expect(await store.read.session(digestSecret(suspendedToken))).toBeNull() + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "membership.change", + outcome: "succeeded", + targetMembershipId: suspended?.id, + context: { reason: "status_suspended" }, + }), + ]) + }) + + 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() + }) + + it("makes a synchronized membership manual when an administrator grants installation administration", async () => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const target = await createAdministrator(store, "target", "target-token", { + role: "member", + source: "synchronized", + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("actor-token", target.membership.id, { + role: "administrator", + }), + ).resolves.toMatchObject({ + role: "administrator", + source: "manual", + status: "active", + }) + }) + + it.each(["suspended", "removed"] as const)( + "makes an administrator's %s decision manual for a synchronized membership", + async (status) => { + const { store } = await database() + await createAdministrator(store, "actor", "actor-token") + const target = await createAdministrator(store, "target", "target-token", { + role: "member", + source: "synchronized", + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("actor-token", target.membership.id, { status }), + ).resolves.toMatchObject({ + source: "manual", + status, + }) + expect(await store.read.session(digestSecret("target-token"))).toBeNull() + }, + ) +}) diff --git a/hub/server/domains/administration/create-service/index.ts b/hub/server/domains/administration/create-service/index.ts new file mode 100644 index 00000000..3e1b6c94 --- /dev/null +++ b/hub/server/domains/administration/create-service/index.ts @@ -0,0 +1,295 @@ +import { z } from "zod" + +import type { + AdministrationDependencies, + AdministrationService, +} from "#server/domains/administration/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { ensureProductAdministrationContinuity } from "#server/domains/prototype-authorization/ensure-administration-continuity" +import { AuthenticationError } from "#server/utils/authentication-error" +import { normalizeEntraIssuer } from "#server/utils/normalize-entra-issuer" + +const invitationSchema = z + .object({ + provider: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/), + issuer: z.url().max(500), + subject: z.string().trim().min(1).max(500), + role: z.enum(["member", "administrator"]), + }) + .superRefine(({ provider, subject }, context) => { + if (provider === "oidc" && !z.uuid().safeParse(subject).success) { + context.addIssue({ + code: "custom", + path: ["subject"], + message: "OIDC invitations require a directory object ID.", + }) + } + }) + .transform((input) => ({ + ...input, + issuer: + input.provider === "oidc" + ? (normalizeEntraIssuer(input.issuer) ?? input.issuer) + : input.issuer, + subject: input.provider === "oidc" ? input.subject.toLowerCase() : input.subject, + })) + .refine(({ provider, issuer }) => provider !== "oidc" || normalizeEntraIssuer(issuer) !== null, { + path: ["issuer"], + message: "OIDC invitations require a supported Entra issuer.", + }) + +const membershipChangeSchema = z + .object({ + role: z.enum(["member", "administrator"]).optional(), + status: z.enum(["active", "suspended", "removed"]).optional(), + }) + .refine(({ role, status }) => role !== undefined || status !== undefined) + +function requireFreshAdministrator( + dependencies: AdministrationDependencies, + sessionToken: string, + fresh = true, +) { + return dependencies.store.lock(async (records) => { + const current = await getCurrentSession( + { + store: dependencies.store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(null) }, + now: dependencies.now, + }, + sessionToken, + records, + ) + if (current.membership.role !== "administrator") { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + if ( + fresh && + (!current.freshAuthorizedAt || + dependencies.now().getTime() - current.freshAuthorizedAt.getTime() >= 5 * 60_000) + ) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Verify your identity again.", + ) + } + return current + }) +} + +export function createAdministrationService( + options: Omit & { now?: () => Date }, +): AdministrationService { + const dependencies: AdministrationDependencies = { + ...options, + now: options.now ?? (() => new Date()), + } + + return { + async list(sessionToken) { + await requireFreshAdministrator(dependencies, sessionToken, false) + const [memberships, invitations] = await Promise.all([ + dependencies.store.read.memberships(), + dependencies.store.read.invitations(), + ]) + return { memberships, invitations } + }, + + async invite(sessionToken, input) { + const parsed = invitationSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_invitation", "The invitation is invalid.") + } + const current = await requireFreshAdministrator(dependencies, sessionToken) + return dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + await records.revokeExpiredInvitations(dependencies.now()) + const identity = + parsed.data.provider === "oidc" + ? await records.externalIdentityByDirectoryObjectId( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject.toLowerCase(), + ) + : await records.externalIdentity( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + ) + const membership = identity ? await records.membership(identity.userId) : null + if (membership && membership.status !== "removed") { + throw new AuthenticationError( + 409, + "identity_already_assigned", + "This identity already has a membership.", + ) + } + const existing = await records.usableInvitation( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + dependencies.now(), + ) + if (existing) { + throw new AuthenticationError(409, "invitation_exists", "An invitation already exists.") + } + const invitation = await records.createInvitation({ + ...parsed.data, + createdByUserId: actor.userId, + expiresAt: new Date(dependencies.now().getTime() + 7 * 24 * 60 * 60_000), + }) + await records.audit({ + event: "membership.invitation.create", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + invitationId: invitation.id, + }) + return invitation + }) + }, + + async revokeInvitation(sessionToken, invitationId) { + const current = await requireFreshAdministrator(dependencies, sessionToken) + await dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + if (!(await records.revokeInvitation(invitationId, dependencies.now()))) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation is no longer available.", + ) + } + await records.audit({ + event: "membership.invitation.revoke", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + invitationId, + }) + }) + }, + + async changeMembership(sessionToken, membershipId, input) { + const parsed = membershipChangeSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_membership_change", + "The membership change is invalid.", + ) + } + const current = await requireFreshAdministrator(dependencies, sessionToken) + return dependencies.store.lock(async (records, database) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + const target = await records.membershipById(membershipId) + if (!target) { + throw new AuthenticationError( + 404, + "membership_not_found", + "The membership was not found.", + ) + } + if (target.status === "removed") { + throw new AuthenticationError( + 409, + "membership_removed", + "Removed memberships require a new invitation.", + ) + } + const nextRole = parsed.data.role ?? target.role + const nextStatus = parsed.data.status ?? target.status + if (nextRole === target.role && nextStatus === target.status) { + throw new AuthenticationError( + 409, + "membership_unchanged", + "The membership already has this role and status.", + ) + } + const removesAdministrator = + target.status === "active" && + target.role === "administrator" && + (nextStatus !== "active" || nextRole !== "administrator") + if (removesAdministrator && (await records.activeAdministratorCount()) <= 1) { + throw new AuthenticationError( + 409, + "last_administrator", + "The final active administrator cannot be changed.", + ) + } + if (target.status === "active" && nextStatus !== "active") { + await ensureProductAdministrationContinuity(database, target.userId) + } + const changed = await records.saveMembership({ + userId: target.userId, + role: nextRole, + status: nextStatus, + source: "manual", + suspendedAt: nextStatus === "suspended" ? dependencies.now() : null, + removedAt: nextStatus === "removed" ? dependencies.now() : null, + }) + if (nextStatus !== "active") { + await records.deleteUserSessions(target.userId) + } + await records.audit({ + event: "membership.change", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + targetMembershipId: target.id, + context: { + reason: nextStatus !== target.status ? `status_${nextStatus}` : `role_${nextRole}`, + }, + }) + return changed + }) + }, + } +} diff --git a/hub/server/domains/administration/create-service/types.ts b/hub/server/domains/administration/create-service/types.ts new file mode 100644 index 00000000..967f8b77 --- /dev/null +++ b/hub/server/domains/administration/create-service/types.ts @@ -0,0 +1,22 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" +import type { MembershipInvitationRecord } from "#server/repositories/membership-invitations/types" + +export interface AdministrationService { + list(sessionToken: string): Promise<{ + memberships: unknown[] + invitations: MembershipInvitationRecord[] + }> + invite(sessionToken: string, input: unknown): Promise + revokeInvitation(sessionToken: string, invitationId: string): Promise + changeMembership( + sessionToken: string, + membershipId: string, + input: unknown, + ): Promise +} + +export interface AdministrationDependencies { + store: IdentityStore + now: () => Date +} diff --git a/hub/server/domains/artifact-publishing/create-service/constants.ts b/hub/server/domains/artifact-publishing/create-service/constants.ts new file mode 100644 index 00000000..ec9f2a16 --- /dev/null +++ b/hub/server/domains/artifact-publishing/create-service/constants.ts @@ -0,0 +1,2 @@ +export const maximumArtifactBytes = 8 * 1024 * 1024 +export const maximumPublishingJsonBytes = 12 * 1024 * 1024 diff --git a/hub/server/domains/artifact-publishing/create-service/index.db.test.ts b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts new file mode 100644 index 00000000..ca68cfc0 --- /dev/null +++ b/hub/server/domains/artifact-publishing/create-service/index.db.test.ts @@ -0,0 +1,1545 @@ +import { createHash, randomUUID } from "node:crypto" +import { readFile } from "node:fs/promises" + +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createArtifactPublishingService } from "#server/domains/artifact-publishing/create-service" +import { maximumArtifactBytes } from "#server/domains/artifact-publishing/create-service/constants" +import type { ArtifactManifest } from "#server/domains/artifact-publishing/create-service/types" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import type { ArtifactStorage } from "#server/integrations/artifact-storage/types" +import { digestSecret } from "#server/utils/digest-secret" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +function artifact(content: string) { + return artifactBytes(Buffer.from(content)) +} + +function artifactBytes(bytes: Buffer) { + return { + bytes, + base64: bytes.toString("base64"), + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + } +} + +function manifest( + prototypeId: string, + publishedArtifact: ReturnType, + overrides: Partial = {}, +): ArtifactManifest { + return { + schemaVersion: 1, + prototypeId, + artifact: { + format: "replica-build", + version: "1", + digest: publishedArtifact.digest, + sizeBytes: publishedArtifact.bytes.length, + builtAt: "2026-10-27T20:00:00.000Z", + }, + provenance: { + provider: "build-system", + sourceUrl: "https://ci.example/runs/123", + revision: "abc123", + ref: "refs/heads/main", + pipeline: "publish", + runId: "123", + }, + ...overrides, + } +} + +async function database(storageOverride?: ArtifactStorage) { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + + const stored = new Map() + const storage: ArtifactStorage = storageOverride ?? { + put(digest, bytes) { + const key = digest.replace(":", "/") + const created = !stored.has(key) + if (created) stored.set(key, bytes.slice()) + return Promise.resolve({ key, created }) + }, + read(key) { + const bytes = stored.get(key) + return bytes + ? Promise.resolve(bytes.slice()) + : Promise.reject(new Error("Artifact not found")) + }, + remove(key) { + stored.delete(key) + return Promise.resolve() + }, + } + + return { + prisma, + storage, + service: createArtifactPublishingService({ database: prisma, storage }), + } +} + +function deferred() { + let resolvePromise!: () => void + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + return { promise, resolve: resolvePromise } +} + +async function waitForBlockedDatabaseQuery(database: PrismaClient, queryPattern: string) { + 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 }, + ) +} + +async function holdPublisherAuthorizationRow( + database: PrismaClient, + target: { type: "credential" | "publisher"; id: string }, +) { + const released = deferred() + const locked = deferred() + const holder = database.$transaction(async (transaction) => { + if (target.type === "credential") { + await transaction.$queryRaw` + SELECT "id" FROM "workload_credentials" WHERE "id" = ${target.id}::uuid FOR UPDATE + ` + } else { + await transaction.$queryRaw` + SELECT "id" FROM "workload_publishers" WHERE "id" = ${target.id}::uuid FOR UPDATE + ` + } + locked.resolve() + await released.promise + }) + await locked.promise + return { holder, release: released.resolve } +} + +async function holdPrototypeRow(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 } +} + +async function createPrototype(prisma: PrismaClient) { + const user = await prisma.user.create({ data: { displayName: "Publisher owner" } }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: user.id, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: user.id, + }, + }) + return { prototype, user, workspace } +} + +async function createAdministratorSession(prisma: PrismaClient, token: string) { + const store = createIdentityStore(prisma) + const authorizedAt = new Date() + const actor = await store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Prototype administrator", + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject: "publisher-administrator", + username: "publisher-administrator", + displayName: user.displayName, + avatarUrl: null, + }) + const provider = await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }) + await records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source: "manual", + }) + await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }) + return user + }) + return { actor, store } +} + +async function createPublisherAdministrator(prisma: PrismaClient, sessionToken: string) { + const { actor, store } = await createAdministratorSession(prisma, sessionToken) + const workspace = await prisma.workspace.create({ + data: { + name: "Publisher workspace", + createdByUserId: actor.id, + grants: { + create: { + userId: actor.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Publisher prototype", + workspaceId: workspace.id, + createdByUserId: actor.id, + }, + }) + const authorization = createPrototypeAuthorizationService({ database: prisma, store }) + return { authorization, prototype } +} + +async function provisionPublisher(prisma: PrismaClient, sessionToken: string) { + const { authorization, prototype } = await createPublisherAdministrator(prisma, sessionToken) + const provisioned = (await authorization.createPublisherCredential(sessionToken, prototype.id, { + name: "CI publisher", + actions: ["publish", "read-status"], + })) as { + publisher: { id: string } + credential: { id: string; token: string } + } + return { + authorization, + prototype, + provisioned, + workloadAuthorization: `Bearer ${provisioned.credential.token}`, + } +} + +async function createCredential( + prisma: PrismaClient, + prototypeId: string, + actions: ("publish" | "read-status")[] = ["publish", "read-status"], +) { + const credentialId = randomUUID() + const token = `rpl_pub_${credentialId}.${"t".repeat(43)}` + const publisher = await prisma.workloadPublisher.create({ + data: { + prototypeId, + name: "CI publisher", + actions, + credentials: { + create: { + id: credentialId, + tokenDigest: digestSecret(token), + }, + }, + }, + }) + return { + authorization: `Bearer ${token}`, + credentialId, + publisher, + token, + } +} + +function publicationInput( + prototypeId: string, + publishedArtifact: ReturnType, + overrides: Record = {}, +) { + return { + manifest: manifest(prototypeId, publishedArtifact), + artifactBase64: publishedArtifact.base64, + ...overrides, + } +} + +function publishedVersion(result: { version: unknown }) { + if ( + result.version === null || + typeof result.version !== "object" || + !("id" in result.version) || + typeof result.version.id !== "string" + ) { + throw new Error("Expected a published version") + } + return { id: result.version.id } +} + +describe("createArtifactPublishingService() database behavior", () => { + it.each([ + { + name: "unknown manifest field", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { ...valid.manifest, unrecognized: true }, + }), + }, + { + name: "unknown artifact field", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + artifact: { ...valid.manifest.artifact, unrecognized: true }, + }, + }), + }, + { + name: "unknown provenance field", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { ...valid.manifest.provenance, unrecognized: true }, + }, + }), + }, + { + name: "unsupported schema version", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { ...valid.manifest, schemaVersion: 2 }, + }), + }, + { + name: "non-HTTP provenance URL", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { ...valid.manifest.provenance, sourceUrl: "file:///private/build" }, + }, + }), + }, + { + name: "malformed provenance URL", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { ...valid.manifest.provenance, sourceUrl: "https://[invalid" }, + }, + }), + }, + { + name: "provenance URL username", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { + ...valid.manifest.provenance, + sourceUrl: "https://publisher@ci.example/runs/123", + }, + }, + }), + }, + { + name: "provenance URL password", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { + ...valid.manifest.provenance, + sourceUrl: "https://publisher:secret@ci.example/runs/123", + }, + }, + }), + }, + { + name: "provenance URL query", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { + ...valid.manifest.provenance, + sourceUrl: "https://ci.example/runs/123?token=private", + }, + }, + }), + }, + { + name: "provenance URL fragment", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { + ...valid.manifest, + provenance: { + ...valid.manifest.provenance, + sourceUrl: "https://ci.example/runs/123#private", + }, + }, + }), + }, + { + name: "different prototype identifier", + change: (valid: ReturnType) => ({ + ...valid, + manifest: { ...valid.manifest, prototypeId: randomUUID() }, + }), + }, + ])("rejects a manifest with $name before creating a version", async ({ change }) => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifact("manifest-validation") + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "manifest-case", + change(publicationInput(prototype.id, publishedArtifact)), + ), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_artifact_manifest", + }) + expect(await context.prisma.prototypeVersion.count()).toBe(0) + }) + + it.each(["provider", "sourceUrl", "revision", "ref", "pipeline", "runId"] as const)( + "rejects a NUL in provenance $field before storage or state changes", + async (field) => { + const put = vi.fn(() => Promise.resolve({ key: "unused", created: true })) + const storage: ArtifactStorage = { + put, + read: () => Promise.reject(new Error("Artifact not found")), + remove: () => Promise.resolve(), + } + const context = await database(storage) + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const input = publicationInput(prototype.id, artifact(`nul-${field}`)) + input.manifest.provenance = { + ...input.manifest.provenance, + [field]: field === "sourceUrl" ? "https://ci.example/runs/\0hidden" : "visible\0hidden", + } + + await expect( + context.service.publish(credential.authorization, prototype.id, `nul-${field}`, input), + ).rejects.toMatchObject({ + status: 422, + code: "invalid_artifact_manifest", + }) + expect(put).not.toHaveBeenCalled() + await expect( + Promise.all([ + context.prisma.prototypeVersion.count(), + context.prisma.artifactPublication.count(), + context.prisma.securityAudit.count(), + ]), + ).resolves.toEqual([0, 0, 0]) + }, + ) + + it("publishes a valid artifact at the decoded size limit", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifactBytes(Buffer.alloc(maximumArtifactBytes, 0x61)) + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "maximum-artifact", + publicationInput(prototype.id, publishedArtifact), + ), + ).resolves.toMatchObject({ replayed: false }) + }) + + it("reports a correctly shaped artifact over the decoded size limit as too large", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifactBytes(Buffer.alloc(maximumArtifactBytes + 1, 0x61)) + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "oversized-artifact", + publicationInput(prototype.id, publishedArtifact), + ), + ).rejects.toMatchObject({ status: 413, code: "artifact_too_large" }) + expect(await context.prisma.prototypeVersion.count()).toBe(0) + }) + + it("rejects noncanonical base64 as an invalid artifact instead of an oversized artifact", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const input = publicationInput(prototype.id, artifact("base64")) + input.artifactBase64 = "AB==" + + await expect( + context.service.publish(credential.authorization, prototype.id, "invalid-base64", input), + ).rejects.toMatchObject({ status: 422, code: "invalid_artifact" }) + }) + + it("rejects malformed workload credential identifiers before querying the database", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const findCredential = vi.spyOn(context.prisma.workloadCredential, "findUnique") + + await expect( + context.service.publish( + `Bearer rpl_pub_00000000-0000-0000-0000-00000000000-.${"t".repeat(43)}`, + prototype.id, + "malformed-credential", + publicationInput(prototype.id, artifact("malformed-credential")), + ), + ).rejects.toMatchObject({ status: 401, code: "publisher_authentication_required" }) + expect(findCredential).not.toHaveBeenCalled() + }) + + it("rejects an artifact whose decoded size differs from the manifest", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifact("size-mismatch") + const input = publicationInput(prototype.id, publishedArtifact) + input.manifest.artifact.sizeBytes += 1 + + await expect( + context.service.publish(credential.authorization, prototype.id, "size-case", input), + ).rejects.toMatchObject({ + status: 422, + code: "artifact_size_mismatch", + }) + expect(await context.prisma.prototypeVersion.count()).toBe(0) + }) + + it("rejects an artifact whose content digest differs from the manifest", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifact("digest-mismatch") + const input = publicationInput(prototype.id, publishedArtifact) + input.manifest.artifact.digest = artifact("different-content").digest + + await expect( + context.service.publish(credential.authorization, prototype.id, "digest-case", input), + ).rejects.toMatchObject({ + status: 422, + code: "artifact_digest_mismatch", + }) + expect(await context.prisma.prototypeVersion.count()).toBe(0) + }) + + it("stores only a digest of a prototype-scoped workload token", async () => { + const context = await database() + const sessionToken = "publisher-administrator-session" + const { actor, store } = await createAdministratorSession(context.prisma, sessionToken) + const workspace = await context.prisma.workspace.create({ + data: { + name: "Credential workspace", + createdByUserId: actor.id, + grants: { + create: { + userId: actor.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await context.prisma.prototype.create({ + data: { + name: "Credential prototype", + workspaceId: workspace.id, + createdByUserId: actor.id, + }, + }) + const authorization = createPrototypeAuthorizationService({ + database: context.prisma, + store, + }) + const provisioned = await authorization.createPublisherCredential(sessionToken, prototype.id, { + name: "CI publisher", + actions: ["publish", "read-status"], + }) + if ( + provisioned === null || + typeof provisioned !== "object" || + !("credential" in provisioned) || + provisioned.credential === null || + typeof provisioned.credential !== "object" || + !("id" in provisioned.credential) || + typeof provisioned.credential.id !== "string" || + !("token" in provisioned.credential) || + typeof provisioned.credential.token !== "string" + ) { + throw new Error("Expected a provisioned workload credential") + } + + const saved = await context.prisma.workloadCredential.findUniqueOrThrow({ + where: { id: provisioned.credential.id }, + }) + + expect(saved.tokenDigest).toBe(digestSecret(provisioned.credential.token)) + expect(saved.tokenDigest).not.toContain(provisioned.credential.token) + expect(JSON.stringify(saved)).not.toContain(provisioned.credential.token) + }) + + it("rejects provisioning a workload credential that is already expired", async () => { + const context = await database() + const sessionToken = "expired-provisioning-session" + const { authorization, prototype } = await createPublisherAdministrator( + context.prisma, + sessionToken, + ) + + await expect( + authorization.createPublisherCredential(sessionToken, prototype.id, { + name: "Expired publisher", + actions: ["publish", "read-status"], + expiresAt: new Date(Date.now() - 1_000).toISOString(), + }), + ).rejects.toMatchObject({ status: 422, code: "invalid_workload_publisher" }) + expect( + await context.prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.publisher.create" }, + }), + ).toBe(0) + }) + + it("accepts and persists an ordinary HTTPS provenance source URL", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const sourceUrl = "https://ci.example/runs/ordinary" + + const result = await context.service.publish( + credential.authorization, + prototype.id, + "ordinary-source-url", + publicationInput(prototype.id, artifact("ordinary-source-url"), { + manifest: manifest(prototype.id, artifact("ordinary-source-url"), { + provenance: { + provider: "build-system", + sourceUrl, + revision: "abc123", + ref: "refs/heads/main", + pipeline: "publish", + runId: "123", + }, + }), + }), + ) + + expect( + await context.prisma.prototypeVersion.findUniqueOrThrow({ + where: { id: publishedVersion(result).id }, + select: { provenanceSourceUrl: true }, + }), + ).toEqual({ provenanceSourceUrl: sourceUrl }) + }) + + it("keeps a revoked credential unusable after its publisher is disabled and re-enabled", async () => { + const context = await database() + const sessionToken = "publisher-lifecycle-session" + const { authorization, prototype, provisioned, workloadAuthorization } = + await provisionPublisher(context.prisma, sessionToken) + + await context.service.publish( + workloadAuthorization, + prototype.id, + "before-disable", + publicationInput(prototype.id, artifact("before-disable")), + ) + await authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: false, + }) + await expect( + context.service.publish( + workloadAuthorization, + prototype.id, + "while-disabled", + publicationInput(prototype.id, artifact("while-disabled")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + + await authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: true, + }) + await context.service.publish( + workloadAuthorization, + prototype.id, + "after-enable", + publicationInput(prototype.id, artifact("after-enable")), + ) + await authorization.revokePublisherCredential( + sessionToken, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ) + await authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: false, + }) + await authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: true, + }) + + await expect( + context.service.publish( + workloadAuthorization, + prototype.id, + "after-revocation", + publicationInput(prototype.id, artifact("after-revocation")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(2) + }) + + it.each([ + { mutation: "revoke", operation: "publish" }, + { mutation: "disable", operation: "publish" }, + { mutation: "revoke", operation: "promote" }, + { mutation: "disable", operation: "promote" }, + ] as const)( + "rejects an already-started $operation after an administrator wins the $mutation race", + async ({ mutation, operation }) => { + const context = await database() + const sessionToken = `${mutation}-${operation}-session` + const { authorization, prototype, provisioned, workloadAuthorization } = + await provisionPublisher(context.prisma, sessionToken) + const published = + operation === "promote" + ? await context.service.publish( + workloadAuthorization, + prototype.id, + `${mutation}-promotion-source`, + publicationInput(prototype.id, artifact(`${mutation}-promotion-source`)), + ) + : null + const version = published ? publishedVersion(published) : null + const authorizationRow = + mutation === "revoke" + ? { type: "credential" as const, id: provisioned.credential.id } + : { type: "publisher" as const, id: provisioned.publisher.id } + const authorizationTable = + mutation === "revoke" ? "workload_credentials" : "workload_publishers" + const before = { + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + channels: await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + orderBy: { name: "asc" }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { + prototypeId: prototype.id, + event: { in: ["prototype.version.publish", "prototype.channel.promote"] }, + }, + }), + } + const rowLock = await holdPublisherAuthorizationRow(context.prisma, authorizationRow) + + const administratorMutation = + mutation === "revoke" + ? authorization.revokePublisherCredential( + sessionToken, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ) + : authorization.setPublisherActive(sessionToken, prototype.id, provisioned.publisher.id, { + active: false, + }) + await waitForBlockedDatabaseQuery(context.prisma, `%${authorizationTable}%`) + + const workloadResult = ( + operation === "publish" + ? context.service.publish( + workloadAuthorization, + prototype.id, + `${mutation}-concurrent-publish`, + publicationInput(prototype.id, artifact(`${mutation}-concurrent-publish`)), + ) + : context.service.promote(workloadAuthorization, prototype.id, "current", { + versionId: version!.id, + }) + ).then( + () => ({ error: null }), + (error: unknown) => ({ error }), + ) + await waitForBlockedDatabaseQuery(context.prisma, "%prototypes%FOR UPDATE%") + + rowLock.release() + await rowLock.holder + await administratorMutation + + expect((await workloadResult).error).toMatchObject({ + status: 403, + code: "publisher_access_denied", + }) + expect({ + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + channels: await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + orderBy: { name: "asc" }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { + prototypeId: prototype.id, + event: { in: ["prototype.version.publish", "prototype.channel.promote"] }, + }, + }), + }).toEqual(before) + }, + ) + + it.each(["publish", "promote"] as const)( + "rejects an already-started %s when its credential expires while waiting for the prototype lock", + async (operation) => { + const context = await database() + const sessionToken = `expiring-${operation}-session` + const { prototype, provisioned, workloadAuthorization } = await provisionPublisher( + context.prisma, + sessionToken, + ) + const published = + operation === "promote" + ? await context.service.publish( + workloadAuthorization, + prototype.id, + "expiration-promotion-source", + publicationInput(prototype.id, artifact("expiration-promotion-source")), + ) + : null + const expiresAt = new Date(Date.now() + 60_000) + await context.prisma.workloadCredential.update({ + where: { id: provisioned.credential.id }, + data: { expiresAt }, + }) + const before = { + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + channels: await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { + prototypeId: prototype.id, + event: { in: ["prototype.version.publish", "prototype.channel.promote"] }, + }, + }), + } + const prototypeLock = await holdPrototypeRow(context.prisma, prototype.id) + onTestFinished(prototypeLock.release) + const workloadResult = + operation === "publish" + ? context.service.publish( + workloadAuthorization, + prototype.id, + "expired-while-blocked", + publicationInput(prototype.id, artifact("expired-while-blocked")), + ) + : context.service.promote(workloadAuthorization, prototype.id, "current", { + versionId: publishedVersion(published!).id, + }) + await waitForBlockedDatabaseQuery(context.prisma, "%prototypes%FOR UPDATE%") + + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(expiresAt.getTime() + 1) + prototypeLock.release() + await prototypeLock.holder + + await expect(workloadResult).rejects.toMatchObject({ + status: 403, + code: "publisher_access_denied", + }) + expect({ + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + channels: await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { + prototypeId: prototype.id, + event: { in: ["prototype.version.publish", "prototype.channel.promote"] }, + }, + }), + }).toEqual(before) + }, + ) + + it("enforces publish and read-status action scopes independently", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const publishOnly = await createCredential(context.prisma, prototype.id, ["publish"]) + const statusOnly = await createCredential(context.prisma, prototype.id, ["read-status"]) + const publishedArtifact = artifact("action-scope") + + await context.service.publish( + publishOnly.authorization, + prototype.id, + "action-scope", + publicationInput(prototype.id, publishedArtifact), + ) + + await expect( + context.service.getStatus(publishOnly.authorization, prototype.id, "action-scope"), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + await expect( + context.service.publish( + statusOnly.authorization, + prototype.id, + "status-cannot-publish", + publicationInput(prototype.id, artifact("status-only")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + }) + + it("rejects status reads after the workload credential expires", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + await context.service.publish( + credential.authorization, + prototype.id, + "expired-status", + publicationInput(prototype.id, artifact("expired-status")), + ) + await context.prisma.workloadCredential.update({ + where: { id: credential.credentialId }, + data: { expiresAt: new Date(Date.now() - 1_000) }, + }) + + await expect( + context.service.getStatus(credential.authorization, prototype.id, "expired-status"), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + }) + + it("rejects a canonical wrong secret for an existing credential before storage or state changes", async () => { + const put = vi.fn((contentDigest: string) => + Promise.resolve({ + key: contentDigest.replace(":", "/"), + created: true, + }), + ) + const storage: ArtifactStorage = { + put, + read: vi.fn(), + remove: vi.fn(), + } + const context = await database(storage) + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + await context.service.publish( + credential.authorization, + prototype.id, + "wrong-secret-status", + publicationInput(prototype.id, artifact("wrong-secret-status")), + ) + put.mockClear() + const wrongToken = `${["rpl", "pub"].join("_")}_${credential.credentialId}.${"w".repeat(43)}` + const wrongAuthorization = `Bearer ${wrongToken}` + const before = { + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + }), + } + + await expect( + context.service.getStatus(wrongAuthorization, prototype.id, "wrong-secret-status"), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + await expect( + context.service.publish( + wrongAuthorization, + prototype.id, + "wrong-secret-publish", + publicationInput(prototype.id, artifact("wrong-secret-publish")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + + expect(put).not.toHaveBeenCalled() + expect({ + publications: await context.prisma.artifactPublication.count({ + where: { prototypeId: prototype.id }, + }), + versions: await context.prisma.prototypeVersion.count({ + where: { prototypeId: prototype.id }, + }), + workloadAudits: await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + }), + }).toEqual(before) + }) + + it("rejects a credential issued for a different prototype", async () => { + const context = await database() + const first = await createPrototype(context.prisma) + const second = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, first.prototype.id) + + await expect( + context.service.publish( + credential.authorization, + second.prototype.id, + "cross-prototype", + publicationInput(second.prototype.id, artifact("isolated")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + expect( + await context.prisma.prototypeVersion.count({ + where: { prototypeId: second.prototype.id }, + }), + ).toBe(0) + }) + + it("returns the same immutable version for an idempotent retry", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const input = publicationInput(prototype.id, artifact("idempotent")) + + const first = await context.service.publish( + credential.authorization, + prototype.id, + "same-request", + input, + ) + const replay = await context.service.publish( + credential.authorization, + prototype.id, + "same-request", + input, + ) + + expect(first).toMatchObject({ replayed: false }) + expect(replay).toMatchObject({ replayed: true, version: publishedVersion(first) }) + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(1) + expect( + await context.prisma.artifactPublication.count({ + where: { workloadPublisherId: credential.publisher.id }, + }), + ).toBe(1) + }) + + it("normalizes uppercase prototype identifiers across publishing operations", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const other = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const uppercasePrototypeId = prototype.id.toUpperCase() + const uppercaseInput = publicationInput( + uppercasePrototypeId, + artifact("uppercase-prototype-id"), + ) + + const first = await context.service.publish( + credential.authorization, + uppercasePrototypeId, + "uppercase-request", + uppercaseInput, + ) + const replay = await context.service.publish( + credential.authorization, + prototype.id, + "uppercase-request", + publicationInput(prototype.id, artifact("uppercase-prototype-id")), + ) + const status = await context.service.getStatus( + credential.authorization, + uppercasePrototypeId, + "uppercase-request", + ) + await context.service.promote(credential.authorization, uppercasePrototypeId, "current", { + versionId: publishedVersion(first).id, + }) + + expect(first).toMatchObject({ replayed: false }) + expect(replay).toMatchObject({ replayed: true, version: publishedVersion(first) }) + expect(status).toMatchObject({ version: publishedVersion(first) }) + await expect( + context.prisma.prototypeChannel.findUniqueOrThrow({ + where: { prototypeId_name: { prototypeId: prototype.id, name: "current" } }, + select: { prototypeVersionId: true }, + }), + ).resolves.toEqual({ prototypeVersionId: publishedVersion(first).id }) + await expect( + context.service.publish( + credential.authorization, + other.prototype.id.toUpperCase(), + "uppercase-cross-prototype", + publicationInput(other.prototype.id.toUpperCase(), artifact("isolated-uppercase")), + ), + ).rejects.toMatchObject({ status: 403, code: "publisher_access_denied" }) + expect( + await context.prisma.prototypeVersion.count({ + where: { prototypeId: other.prototype.id }, + }), + ).toBe(0) + }) + + it("commits a promoted publication, version, channel, and audit together", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const input = publicationInput(prototype.id, artifact("publish-and-promote"), { + promoteChannel: "preview", + }) + + const result = await context.service.publish( + credential.authorization, + prototype.id, + "publish-and-promote", + input, + ) + const version = publishedVersion(result) + + expect(result).toMatchObject({ replayed: false, promotedChannel: "preview" }) + expect( + await context.prisma.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + select: { id: true, workloadPublisherId: true }, + }), + ).toEqual([{ id: version.id, workloadPublisherId: credential.publisher.id }]) + expect( + await context.prisma.artifactPublication.findMany({ + where: { prototypeId: prototype.id }, + select: { prototypeVersionId: true, workloadPublisherId: true }, + }), + ).toEqual([ + { + prototypeVersionId: version.id, + workloadPublisherId: credential.publisher.id, + }, + ]) + expect( + await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + select: { name: true, prototypeVersionId: true }, + }), + ).toEqual([{ name: "preview", prototypeVersionId: version.id }]) + expect( + await context.prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + select: { prototypeVersionId: true, workloadPublisherId: true }, + }), + ).toEqual([ + { + prototypeVersionId: version.id, + workloadPublisherId: credential.publisher.id, + }, + ]) + }) + + it("does not move a later channel promotion when replaying an earlier publication", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const firstInput = publicationInput(prototype.id, artifact("earlier-publication"), { + promoteChannel: "current", + }) + const first = await context.service.publish( + credential.authorization, + prototype.id, + "earlier-publication", + firstInput, + ) + const second = await context.service.publish( + credential.authorization, + prototype.id, + "later-publication", + publicationInput(prototype.id, artifact("later-publication"), { + promoteChannel: "current", + }), + ) + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "earlier-publication", + firstInput, + ), + ).resolves.toMatchObject({ + replayed: true, + version: publishedVersion(first), + promotedChannel: "current", + }) + expect( + await context.prisma.prototypeChannel.findUniqueOrThrow({ + where: { prototypeId_name: { prototypeId: prototype.id, name: "current" } }, + select: { prototypeVersionId: true }, + }), + ).toEqual({ prototypeVersionId: publishedVersion(second).id }) + }) + + it("isolates status reads for publishers that reuse the same idempotency key", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const firstPublisher = await createCredential(context.prisma, prototype.id) + const secondPublisher = await createCredential(context.prisma, prototype.id) + const first = await context.service.publish( + firstPublisher.authorization, + prototype.id, + "shared-request-key", + publicationInput(prototype.id, artifact("first publisher version")), + ) + const second = await context.service.publish( + secondPublisher.authorization, + prototype.id, + "shared-request-key", + publicationInput(prototype.id, artifact("second publisher version")), + ) + + await expect( + context.service.getStatus(firstPublisher.authorization, prototype.id, "shared-request-key"), + ).resolves.toMatchObject({ version: publishedVersion(first) }) + await expect( + context.service.getStatus(secondPublisher.authorization, prototype.id, "shared-request-key"), + ).resolves.toMatchObject({ version: publishedVersion(second) }) + }) + + it("rejects reuse of an idempotency key for a different publication", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const publishedArtifact = artifact("idempotency-conflict") + const first = publicationInput(prototype.id, publishedArtifact) + const conflicting = publicationInput(prototype.id, publishedArtifact) + conflicting.manifest.provenance.runId = "different-run" + + await context.service.publish(credential.authorization, prototype.id, "reused-key", first) + await expect( + context.service.publish(credential.authorization, prototype.id, "reused-key", conflicting), + ).rejects.toMatchObject({ status: 409, code: "idempotency_conflict" }) + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(1) + }) + + it("serializes concurrent publications into ordered immutable versions", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + + const results = await Promise.all([ + context.service.publish( + credential.authorization, + prototype.id, + "concurrent-one", + publicationInput(prototype.id, artifact("concurrent-one")), + ), + context.service.publish( + credential.authorization, + prototype.id, + "concurrent-two", + publicationInput(prototype.id, artifact("concurrent-two")), + ), + ]) + const versions = await context.prisma.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { sequence: "asc" }, + }) + + expect(results.map(({ replayed }) => replayed)).toEqual([false, false]) + expect(versions.map(({ sequence }) => sequence)).toEqual([1, 2]) + const migration = await readFile( + new URL( + "../../../../schema/migrations/20261027230000_artifact_ingestion/migration.sql", + import.meta.url, + ), + "utf8", + ) + const immutableVersionStart = migration.indexOf( + "CREATE FUNCTION reject_prototype_version_mutation()", + ) + expect(immutableVersionStart).toBeGreaterThanOrEqual(0) + const immutableVersionSql = migration.slice(immutableVersionStart) + await context.prisma.$executeRawUnsafe(immutableVersionSql) + try { + await expect( + context.prisma.prototypeVersion.update({ + where: { id: versions[0]!.id }, + data: { label: "rewritten" }, + }), + ).rejects.toThrow(/prototype versions are immutable/i) + await expect( + context.prisma.prototypeVersion.delete({ where: { id: versions[1]!.id } }), + ).rejects.toThrow(/prototype versions are immutable/i) + } finally { + await context.prisma.$executeRawUnsafe( + "DROP TRIGGER prototype_versions_immutable ON prototype_versions", + ) + await context.prisma.$executeRawUnsafe("DROP FUNCTION reject_prototype_version_mutation()") + } + }) + + it("rejects an already-started publication when archive acquires the prototype lock first", async () => { + const putStarted = deferred() + const releasePut = deferred() + const storage: ArtifactStorage = { + async put(digest) { + putStarted.resolve() + await releasePut.promise + return { key: digest.replace(":", "/"), created: true } + }, + read: vi.fn(), + remove: vi.fn(), + } + const context = await database(storage) + const sessionToken = "archive-race-session" + const { authorization, prototype, workloadAuthorization } = await provisionPublisher( + context.prisma, + sessionToken, + ) + const publication = context.service.publish( + workloadAuthorization, + prototype.id, + "archive-race", + publicationInput(prototype.id, artifact("archive-race")), + ) + await putStarted.promise + + await authorization.archivePrototype(sessionToken, prototype.id) + releasePut.resolve() + + await expect(publication).rejects.toMatchObject({ + status: 409, + code: "prototype_archived", + }) + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.artifactPublication.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + }), + ).toBe(0) + }) + + it("promotes and rolls back a channel by moving one atomic pointer", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const first = await context.service.publish( + credential.authorization, + prototype.id, + "channel-one", + publicationInput(prototype.id, artifact("channel-one")), + ) + const second = await context.service.publish( + credential.authorization, + prototype.id, + "channel-two", + publicationInput(prototype.id, artifact("channel-two")), + ) + const firstVersion = publishedVersion(first) + const secondVersion = publishedVersion(second) + + await context.service.promote(credential.authorization, prototype.id, "current", { + versionId: firstVersion.id, + }) + await context.service.promote(credential.authorization, prototype.id, "current", { + versionId: secondVersion.id, + }) + await context.service.promote(credential.authorization, prototype.id, "preview", { + versionId: secondVersion.id, + }) + await context.service.promote(credential.authorization, prototype.id, "current", { + versionId: firstVersion.id, + }) + + expect( + await context.prisma.prototypeChannel.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { name: "asc" }, + select: { name: true, prototypeVersionId: true }, + }), + ).toEqual([ + { name: "current", prototypeVersionId: firstVersion.id }, + { name: "preview", prototypeVersionId: secondVersion.id }, + ]) + expect( + await context.prisma.prototypeVersion.findMany({ + where: { prototypeId: prototype.id }, + orderBy: { sequence: "asc" }, + select: { id: true, sequence: true }, + }), + ).toEqual([ + { id: firstVersion.id, sequence: 1 }, + { id: secondVersion.id, sequence: 2 }, + ]) + }) + + it("blocks publication and current or preview promotion after a prototype is archived", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + const published = await context.service.publish( + credential.authorization, + prototype.id, + "before-archive", + publicationInput(prototype.id, artifact("before-archive")), + ) + const version = publishedVersion(published) + await context.prisma.prototype.update({ + where: { id: prototype.id }, + data: { archivedAt: new Date() }, + }) + + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "after-archive", + publicationInput(prototype.id, artifact("after-archive")), + ), + ).rejects.toMatchObject({ status: 409, code: "prototype_archived" }) + for (const channel of ["current", "preview"] as const) { + await expect( + context.service.promote(credential.authorization, prototype.id, channel, { + versionId: version.id, + }), + ).rejects.toMatchObject({ status: 409, code: "prototype_archived" }) + } + + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(1) + expect( + await context.prisma.prototypeChannel.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + }) + + it("rolls back a promoted publication, version, channel, and audit together", async () => { + const context = await database() + const { prototype } = await createPrototype(context.prisma) + const credential = await createCredential(context.prisma, prototype.id) + + await context.prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_version_publish_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = 'prototype.version.publish' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await context.prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_version_publish_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_version_publish_audit(); + `) + try { + await expect( + context.service.publish( + credential.authorization, + prototype.id, + "audit-failure", + publicationInput(prototype.id, artifact("audit-failure"), { + promoteChannel: "current", + }), + ), + ).rejects.toThrow("audit unavailable") + } finally { + await context.prisma.$executeRawUnsafe( + "DROP TRIGGER reject_version_publish_audit ON security_audits", + ) + await context.prisma.$executeRawUnsafe("DROP FUNCTION reject_version_publish_audit()") + } + + expect( + await context.prisma.prototypeVersion.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.artifactPublication.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.prototypeChannel.count({ where: { prototypeId: prototype.id } }), + ).toBe(0) + expect( + await context.prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.version.publish" }, + }), + ).toBe(0) + }) +}) diff --git a/hub/server/domains/artifact-publishing/create-service/index.ts b/hub/server/domains/artifact-publishing/create-service/index.ts new file mode 100644 index 00000000..230b0870 --- /dev/null +++ b/hub/server/domains/artifact-publishing/create-service/index.ts @@ -0,0 +1,482 @@ +import { createHash } from "node:crypto" + +import { z } from "zod" + +import type { ArtifactStorage } from "#server/integrations/artifact-storage/types" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" +import { AuthenticationError } from "#server/utils/authentication-error" + +import { maximumArtifactBytes } from "./constants.ts" +import type { ArtifactPublishingService, PrototypeChannelName, PublisherAction } from "./types.ts" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +const boundedIdentifier = z + .string() + .trim() + .min(1) + .max(255) + .refine((value) => !value.includes("\0")) +function isSafeProvenanceUrl(value: string): boolean { + if (value.includes("\0")) return false + try { + const url = new URL(value) + return ( + ["http:", "https:"].includes(url.protocol) && + url.username.length === 0 && + url.password.length === 0 && + url.search.length === 0 && + url.hash.length === 0 + ) + } catch { + return false + } +} + +const manifestSchema = z + .object({ + schemaVersion: z.literal(1), + prototypeId: z.uuid(), + artifact: z + .object({ + format: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), + version: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/), + digest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + sizeBytes: z.number().int().min(0), + builtAt: z.iso.datetime({ offset: true }), + }) + .strict(), + provenance: z + .object({ + provider: z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/), + sourceUrl: z.string().max(2_048).refine(isSafeProvenanceUrl).optional(), + revision: boundedIdentifier.optional(), + ref: boundedIdentifier.optional(), + pipeline: boundedIdentifier.optional(), + runId: boundedIdentifier.optional(), + }) + .strict(), + }) + .strict() +const publishSchema = z + .object({ + manifest: manifestSchema, + artifactBase64: z.string(), + promoteChannel: z.enum(["current", "preview"]).optional(), + }) + .strict() +const promotionSchema = z.object({ versionId: z.uuid() }).strict() +const channelSchema = z.enum(["current", "preview"]) +const prototypeIdSchema = z.uuid() +const idempotencyKeySchema = z + .string() + .min(1) + .max(200) + .regex(/^[\x21-\x7e]+$/) +const authorizationSchema = + /^Bearer (rpl_pub_([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.([A-Za-z0-9_-]{43}))$/i + +function error(status: number, code: string, message: string): never { + throw new AuthenticationError(status, code, message) +} + +function parsePrototypeId(prototypeId: string): string { + const parsed = prototypeIdSchema.safeParse(prototypeId) + if (!parsed.success) { + error(404, "prototype_not_found", "The prototype was not found.") + } + return parsed.data.toLowerCase() +} + +function parseIdempotencyKey(value: string): string { + const parsed = idempotencyKeySchema.safeParse(value) + if (!parsed.success) { + error(422, "invalid_idempotency_key", "The idempotency key is invalid.") + } + return parsed.data +} + +function parseArtifactBase64(value: string): Uint8Array { + if (value.length % 4 !== 0) { + error(422, "invalid_artifact", "The artifact encoding is invalid.") + } + const bytes = Buffer.from(value, "base64") + if (bytes.toString("base64") !== value) { + error(422, "invalid_artifact", "The artifact encoding is invalid.") + } + if (bytes.length > maximumArtifactBytes) { + error(413, "artifact_too_large", "The artifact exceeds the maximum size.") + } + return bytes +} + +function sha256(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}` +} + +async function lockPrototype( + database: Prisma.TransactionClient, + prototypeId: string, +): Promise { + await database.$queryRaw`SELECT "id" FROM "prototypes" WHERE "id" = ${prototypeId}::uuid FOR UPDATE` +} + +function versionResponse(version: { + id: string + prototypeId: string + sequence: number + label: string | null + artifactFormat: string | null + artifactVersion: string | null + contentDigest: string | null + sizeBytes: number | null + builtAt: Date | null + provenanceProvider: string | null + provenanceSourceUrl: string | null + provenanceRevision: string | null + provenanceRef: string | null + provenancePipeline: string | null + provenanceRunId: string | null + createdAt: Date +}) { + return { + id: version.id, + prototypeId: version.prototypeId, + sequence: version.sequence, + label: version.label, + artifact: { + format: version.artifactFormat, + version: version.artifactVersion, + digest: version.contentDigest, + sizeBytes: version.sizeBytes, + builtAt: version.builtAt, + }, + provenance: { + provider: version.provenanceProvider, + sourceUrl: version.provenanceSourceUrl, + revision: version.provenanceRevision, + ref: version.provenanceRef, + pipeline: version.provenancePipeline, + runId: version.provenanceRunId, + }, + createdAt: version.createdAt, + } +} + +export function createArtifactPublishingService({ + database, + storage, +}: { + database: PrismaClient + storage: ArtifactStorage +}): ArtifactPublishingService { + type PublisherAuthentication = { + credentialId: string + publisherId: string + prototypeId: string + tokenDigest: string + } + + async function requireUnarchivedPrototype(prototypeId: string) { + const prototype = await database.prototype.findUnique({ + where: { id: prototypeId }, + select: { archivedAt: true }, + }) + if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") + if (prototype.archivedAt) { + error(409, "prototype_archived", "Archived prototypes cannot be published.") + } + } + + async function authenticate(authorization: string, prototypeId: string, action: PublisherAction) { + const normalizedPrototypeId = parsePrototypeId(prototypeId) + const match = authorizationSchema.exec(authorization) + if (!match?.[1] || !match[2]) { + error(401, "publisher_authentication_required", "Publisher authentication failed.") + } + const [, token, credentialId] = match + const tokenDigest = digestSecret(token) + const credential = await database.workloadCredential.findUnique({ + where: { id: credentialId }, + include: { workloadPublisher: true }, + }) + if ( + !credential || + credential.revokedAt || + (credential.expiresAt && credential.expiresAt <= new Date()) || + !credential.workloadPublisher.active || + credential.workloadPublisher.prototypeId !== normalizedPrototypeId || + !credential.workloadPublisher.actions.includes(action) || + !secretsMatch(credential.tokenDigest, tokenDigest) + ) { + error(403, "publisher_access_denied", "Publisher access is denied.") + } + return { + credentialId: credential.id, + publisherId: credential.workloadPublisher.id, + prototypeId: normalizedPrototypeId, + tokenDigest, + } + } + + async function requireCurrentPublisherAuthorization( + transaction: Prisma.TransactionClient, + authentication: PublisherAuthentication, + prototypeId: string, + action: PublisherAction, + ) { + const credential = await transaction.workloadCredential.findUnique({ + where: { id: authentication.credentialId }, + include: { workloadPublisher: true }, + }) + if ( + !credential || + credential.revokedAt || + (credential.expiresAt && credential.expiresAt <= new Date()) || + !credential.workloadPublisher.active || + credential.workloadPublisher.id !== authentication.publisherId || + credential.workloadPublisher.prototypeId !== authentication.prototypeId || + !credential.workloadPublisher.actions.includes(action) || + !secretsMatch(credential.tokenDigest, authentication.tokenDigest) + ) { + error(403, "publisher_access_denied", "Publisher access is denied.") + } + return credential.workloadPublisher + } + + return { + async publish(authorization, prototypeId, idempotencyKey, input) { + const authentication = await authenticate(authorization, prototypeId, "publish") + const normalizedPrototypeId = authentication.prototypeId + await requireUnarchivedPrototype(normalizedPrototypeId) + const parsed = publishSchema.safeParse(input) + if ( + !parsed.success || + parsed.data.manifest.prototypeId.toLowerCase() !== normalizedPrototypeId + ) { + error(422, "invalid_artifact_manifest", "The artifact manifest is invalid.") + } + if ( + parsed.data.manifest.artifact.sizeBytes > maximumArtifactBytes || + parsed.data.artifactBase64.length > Math.ceil((maximumArtifactBytes * 4) / 3) + 4 + ) { + error(413, "artifact_too_large", "The artifact exceeds the maximum size.") + } + const keyDigest = digestSecret(parseIdempotencyKey(idempotencyKey)) + const bytes = parseArtifactBase64(parsed.data.artifactBase64) + if (bytes.length !== parsed.data.manifest.artifact.sizeBytes) { + error(422, "artifact_size_mismatch", "The artifact size does not match the manifest.") + } + if (sha256(bytes) !== parsed.data.manifest.artifact.digest) { + error(422, "artifact_digest_mismatch", "The artifact digest does not match the manifest.") + } + const requestDigest = digestSecret( + JSON.stringify({ + manifest: { ...parsed.data.manifest, prototypeId: normalizedPrototypeId }, + promoteChannel: parsed.data.promoteChannel ?? null, + }), + ) + const stored = await storage.put(parsed.data.manifest.artifact.digest, bytes) + + return database.$transaction(async (transaction) => { + await lockPrototype(transaction, normalizedPrototypeId) + const publisher = await requireCurrentPublisherAuthorization( + transaction, + authentication, + normalizedPrototypeId, + "publish", + ) + const prototype = await transaction.prototype.findUnique({ + where: { id: normalizedPrototypeId }, + }) + if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") + if (prototype.archivedAt) { + error(409, "prototype_archived", "Archived prototypes cannot receive versions.") + } + const existing = await transaction.artifactPublication.findUnique({ + where: { + workloadPublisherId_idempotencyKeyDigest: { + workloadPublisherId: publisher.id, + idempotencyKeyDigest: keyDigest, + }, + }, + include: { prototypeVersion: true }, + }) + if (existing) { + if (existing.requestDigest !== requestDigest) { + error( + 409, + "idempotency_conflict", + "The idempotency key was already used for a different publication.", + ) + } + return { + version: versionResponse(existing.prototypeVersion), + replayed: true, + promotedChannel: parsed.data.promoteChannel ?? null, + } + } + const duplicate = await transaction.prototypeVersion.findFirst({ + where: { + prototypeId: normalizedPrototypeId, + contentDigest: parsed.data.manifest.artifact.digest, + }, + }) + if (duplicate) { + error(409, "artifact_already_published", "This artifact was already published.") + } + const latest = await transaction.prototypeVersion.findFirst({ + where: { prototypeId: normalizedPrototypeId }, + orderBy: { sequence: "desc" }, + }) + const manifest = parsed.data.manifest + const version = await transaction.prototypeVersion.create({ + data: { + prototypeId: normalizedPrototypeId, + workloadPublisherId: publisher.id, + sequence: (latest?.sequence ?? 0) + 1, + artifactFormat: manifest.artifact.format, + artifactVersion: manifest.artifact.version, + contentDigest: manifest.artifact.digest, + sizeBytes: manifest.artifact.sizeBytes, + builtAt: new Date(manifest.artifact.builtAt), + provenanceProvider: manifest.provenance.provider, + provenanceSourceUrl: manifest.provenance.sourceUrl ?? null, + provenanceRevision: manifest.provenance.revision ?? null, + provenanceRef: manifest.provenance.ref ?? null, + provenancePipeline: manifest.provenance.pipeline ?? null, + provenanceRunId: manifest.provenance.runId ?? null, + storageKey: stored.key, + }, + }) + await transaction.artifactPublication.create({ + data: { + prototypeId: normalizedPrototypeId, + workloadPublisherId: publisher.id, + prototypeVersionId: version.id, + idempotencyKeyDigest: keyDigest, + requestDigest, + }, + }) + if (parsed.data.promoteChannel) { + await transaction.prototypeChannel.upsert({ + where: { + prototypeId_name: { + prototypeId: normalizedPrototypeId, + name: parsed.data.promoteChannel, + }, + }, + create: { + prototypeId: normalizedPrototypeId, + prototypeVersionId: version.id, + name: parsed.data.promoteChannel, + }, + update: { prototypeVersionId: version.id }, + }) + } + await saveSecurityAudit(transaction, { + event: "prototype.version.publish", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + workspaceId: prototype.workspaceId, + prototypeId: normalizedPrototypeId, + prototypeVersionId: version.id, + workloadPublisherId: publisher.id, + context: { + source: "workload_publisher", + ...(parsed.data.promoteChannel + ? { reason: `channel_${parsed.data.promoteChannel}` } + : {}), + }, + }) + return { + version: versionResponse(version), + replayed: false, + promotedChannel: parsed.data.promoteChannel ?? null, + } + }) + }, + + async getStatus(authorization, prototypeId, idempotencyKey) { + const authentication = await authenticate(authorization, prototypeId, "read-status") + const publication = await database.artifactPublication.findUnique({ + where: { + workloadPublisherId_idempotencyKeyDigest: { + workloadPublisherId: authentication.publisherId, + idempotencyKeyDigest: digestSecret(parseIdempotencyKey(idempotencyKey)), + }, + }, + include: { prototypeVersion: true }, + }) + if (!publication || publication.prototypeId !== authentication.prototypeId) { + error(404, "publication_not_found", "The publication was not found.") + } + return { + publicationId: publication.id, + version: versionResponse(publication.prototypeVersion), + } + }, + + async promote(authorization, prototypeId, channel, input) { + const authentication = await authenticate(authorization, prototypeId, "publish") + const normalizedPrototypeId = authentication.prototypeId + const parsedChannel = channelSchema.safeParse(channel) + const parsed = promotionSchema.safeParse(input) + if (!parsedChannel.success || !parsed.success) { + error(422, "invalid_promotion", "The promotion is invalid.") + } + return database.$transaction(async (transaction) => { + await lockPrototype(transaction, normalizedPrototypeId) + const publisher = await requireCurrentPublisherAuthorization( + transaction, + authentication, + normalizedPrototypeId, + "publish", + ) + const prototype = await transaction.prototype.findUnique({ + where: { id: normalizedPrototypeId }, + }) + if (!prototype) error(404, "prototype_not_found", "The prototype was not found.") + if (prototype.archivedAt) { + error(409, "prototype_archived", "Archived prototypes cannot be promoted.") + } + const version = await transaction.prototypeVersion.findFirst({ + where: { id: parsed.data.versionId, prototypeId: normalizedPrototypeId }, + }) + if (!version) error(404, "version_not_found", "The version was not found.") + const pointer = await transaction.prototypeChannel.upsert({ + where: { + prototypeId_name: { + prototypeId: normalizedPrototypeId, + name: parsedChannel.data satisfies PrototypeChannelName, + }, + }, + create: { + prototypeId: normalizedPrototypeId, + prototypeVersionId: version.id, + name: parsedChannel.data, + }, + update: { prototypeVersionId: version.id }, + }) + await saveSecurityAudit(transaction, { + event: "prototype.channel.promote", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + workspaceId: prototype.workspaceId, + prototypeId: normalizedPrototypeId, + prototypeVersionId: version.id, + workloadPublisherId: publisher.id, + context: { + source: "workload_publisher", + reason: `channel_${parsedChannel.data}`, + }, + }) + return pointer + }) + }, + } +} diff --git a/hub/server/domains/artifact-publishing/create-service/types.ts b/hub/server/domains/artifact-publishing/create-service/types.ts new file mode 100644 index 00000000..54a12a56 --- /dev/null +++ b/hub/server/domains/artifact-publishing/create-service/types.ts @@ -0,0 +1,38 @@ +export type PublisherAction = "publish" | "read-status" +export type PrototypeChannelName = "current" | "preview" + +export interface ArtifactManifest { + schemaVersion: 1 + prototypeId: string + artifact: { + format: string + version: string + digest: string + sizeBytes: number + builtAt: string + } + provenance: { + provider: string + sourceUrl?: string + revision?: string + ref?: string + pipeline?: string + runId?: string + } +} + +export interface ArtifactPublishingService { + publish( + authorization: string, + prototypeId: string, + idempotencyKey: string, + input: unknown, + ): Promise<{ version: unknown; replayed: boolean; promotedChannel: string | null }> + getStatus(authorization: string, prototypeId: string, idempotencyKey: string): Promise + promote( + authorization: string, + prototypeId: string, + channel: string, + input: unknown, + ): Promise +} diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts new file mode 100644 index 00000000..340419fe --- /dev/null +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -0,0 +1,357 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function completeGitHubAuthorization( + dependencies: AuthenticationDependencies, + code: string, + state: string, + browserToken: string, +): Promise<{ sessionToken?: string; purpose: AuthorizationPurpose }> { + const { admissionPolicy, configuration, github, now, providerRevisionId, store } = dependencies + if (!configuration || !github || !providerRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "GitHub authentication is not configured.", + ) + } + const attempt = await store.lock((records) => + records.consumeAuthorizationAttempt(digestSecret(state)), + ) + if ( + !attempt || + !browserToken || + !secretsMatch(digestSecret(browserToken), attempt.browserDigest) || + attempt.authenticationProviderRevisionId !== providerRevisionId + ) { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid or expired.", + ) + } + if (attempt.purpose === "link-identity") { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid.", + ) + } + const purpose: AuthorizationPurpose = attempt.purpose + let profile: GitHubUserProfile + let admissionBaseline: Awaited> = null + let admissionFailure: AuthenticationError | null = null + try { + profile = await github.identify(code, attempt.pkceVerifier) + } catch { + await store.lock((records) => + records.audit({ + event: "authentication.provider_failed", + outcome: "failed", + actorUserId: null, + sessionId: attempt.originatingSessionId, + authenticationProviderRevisionId: providerRevisionId, + }), + ) + throw new AuthenticationError( + 503, + "authentication_provider_failed", + "The authentication provider failed.", + ) + } + if (attempt.purpose === "sign-in") { + try { + const admission = await admissionPolicy.admit(profile) + admissionBaseline = admission === true ? "legacy" : admission || null + } catch (error) { + if (error instanceof AuthenticationError) { + admissionFailure = error + } else { + admissionFailure = new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + } + } + const result = await store.lock(async (records, transaction) => { + const [currentProvider, stateRecord] = await Promise.all([ + records.currentAuthenticationProviderRevision("github"), + records.identityStoreState(), + ]) + if ( + currentProvider?.id !== providerRevisionId || + stateRecord.sessionGeneration !== attempt.sessionGeneration + ) { + throw new AuthenticationError( + 401, + "authorization_superseded", + "The authorization request was superseded.", + ) + } + if (purpose === "verify") { + const session = attempt.originatingSessionId + ? await records.sessionById(attempt.originatingSessionId) + : null + if ( + !session || + session.externalIdentity.provider !== "github" || + session.externalIdentity.issuer !== configuration.issuer || + session.externalIdentity.subject !== profile.subject + ) { + throw new AuthenticationError(403, "identity_mismatch", "Verify the same GitHub identity.") + } + await records.verifySession(session.id, now()) + await records.audit({ + event: "session.verify", + outcome: "succeeded", + actorUserId: session.userId, + sessionId: session.id, + authenticationProviderRevisionId: providerRevisionId, + }) + return { purpose: "verify" as const } + } + let identity = await records.externalIdentity("github", configuration.issuer, profile.subject) + let membership = identity ? await records.membership(identity.userId) : null + const synchronizableMembership = + admissionBaseline !== null && + membership?.status !== "suspended" && + (membership === null || + (membership.source === "synchronized" && membership.status === "removed")) + const invitation = + purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" + ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) + : null + if ( + purpose === "sign-in" && + admissionFailure && + !(membership?.status === "active" && membership.source !== "synchronized") && + !invitation + ) { + await records.audit({ + event: "authentication.admission_failed", + outcome: "failed", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: admissionFailure.code }, + }) + return { failure: admissionFailure, purpose } + } + if ( + purpose === "sign-in" && + membership?.status === "active" && + membership.source === "synchronized" && + admissionPolicy.name === "github-synchronized" && + !admissionBaseline + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) + return { denied: true as const } + } + if ( + purpose === "sign-in" && + membership?.status !== "active" && + !invitation && + !synchronizableMembership + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: membership?.status ?? "unassigned" }, + }) + return { denied: true as const } + } + if (purpose === "bootstrap") { + const activeAdministrators = await records.activeAdministratorCount() + const completed = activeAdministrators === 0 && (await records.completeBootstrap(now())) + if (!completed) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason: "unavailable" }, + }) + return { denied: true as const } + } + } else if (purpose === "recovery" && (await records.activeAdministratorCount()) > 0) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason: "active_administrator_exists" }, + }) + return { denied: true as const } + } + if (!identity) { + const user = await records.createUser({ + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: configuration.issuer, + subject: profile.subject, + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } else { + await records.updateUser(identity.userId, { + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: identity.userId, + provider: "github", + issuer: configuration.issuer, + subject: profile.subject, + username: profile.username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } + membership = await records.membership(identity.userId) + if (purpose === "bootstrap") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "administrator", + source: "bootstrap", + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: "membership.bootstrap", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + }) + } else if (purpose === "recovery") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "administrator", + source: "recovery", + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: "membership.recovery", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + }) + } else if (!membership || membership.status !== "active") { + if (invitation && membership?.status !== "suspended") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: invitation.role, + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + const consumed = await records.consumeInvitation( + invitation.id, + identity.id, + membership.id, + now(), + ) + if (!consumed) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation changed. Start again.", + ) + } + await records.audit({ + event: "membership.invitation.activate", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + invitationId: invitation.id, + }) + } else if (synchronizableMembership) { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "member", + source: admissionBaseline === "legacy" ? "legacy-synchronized" : "synchronized", + suspendedAt: null, + removedAt: null, + }) + } + } + if (admissionBaseline && admissionBaseline !== "legacy") { + try { + await admissionPolicy.saveBaseline?.(transaction, identity.userId, admissionBaseline) + } catch (error) { + if (!( + error instanceof AuthenticationError && + error.code === "github_authorization_changed" && + membership?.source !== "synchronized" + )) { + throw error + } + } + } + const sessionToken = randomBytes(32).toString("base64url") + const session = await records.createSession({ + userId: identity.userId, + externalIdentityId: identity.id, + authenticationProviderRevisionId: providerRevisionId, + tokenDigest: digestSecret(sessionToken), + signedInAt: now(), + freshAuthorizedAt: now(), + }) + await records.audit({ + event: "session.sign_in", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: session.id, + authenticationProviderRevisionId: providerRevisionId, + }) + return { sessionToken, purpose, denied: false as const } + }) + if ("denied" in result && result.denied) { + throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + } + if ("failure" in result) { + if (result.failure instanceof Error) throw result.failure + throw new AuthenticationError(503, "admission_failed", "Admission failed.") + } + return result +} diff --git a/hub/server/domains/authentication/complete-oidc-authorization/index.ts b/hub/server/domains/authentication/complete-oidc-authorization/index.ts new file mode 100644 index 00000000..50bf8178 --- /dev/null +++ b/hub/server/domains/authentication/complete-oidc-authorization/index.ts @@ -0,0 +1,402 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function completeOidcAuthorization( + dependencies: AuthenticationDependencies, + code: string, + state: string, + browserToken: string, +): Promise<{ sessionToken?: string; purpose: AuthorizationPurpose }> { + const { now, oidc, oidcConfiguration, oidcProviderRevisionId, store } = dependencies + if (!oidcConfiguration?.enabled || !oidc || !oidcProviderRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "OIDC authentication is not configured.", + ) + } + const attempt = await store.lock((records) => + records.consumeAuthorizationAttempt(digestSecret(state)), + ) + if ( + !attempt || + !attempt.nonce || + !browserToken || + !secretsMatch(digestSecret(browserToken), attempt.browserDigest) || + attempt.authenticationProviderRevisionId !== oidcProviderRevisionId || + attempt.expiresAt <= now() + ) { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid or expired.", + ) + } + if (attempt.purpose === "link-identity") { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid.", + ) + } + const purpose: AuthorizationPurpose = attempt.purpose + let profile + try { + profile = + purpose === "verify" + ? await oidc.identify(code, attempt.pkceVerifier, attempt.nonce, { + authenticatedAfter: new Date(attempt.expiresAt.getTime() - 10 * 60_000), + }) + : await oidc.identify(code, attempt.pkceVerifier, attempt.nonce) + } catch { + await store.lock((records) => + records.audit({ + event: "authentication.provider_failed", + outcome: "failed", + actorUserId: null, + sessionId: attempt.originatingSessionId, + authenticationProviderRevisionId: oidcProviderRevisionId, + }), + ) + throw new AuthenticationError( + 503, + "authentication_provider_failed", + "The authentication provider failed.", + ) + } + const result = await store.lock(async (records) => { + const [currentProvider, stateRecord] = await Promise.all([ + records.currentAuthenticationProviderRevision("oidc"), + records.identityStoreState(), + ]) + if ( + currentProvider?.id !== oidcProviderRevisionId || + stateRecord.sessionGeneration !== attempt.sessionGeneration + ) { + throw new AuthenticationError( + 401, + "authorization_superseded", + "The authorization request was superseded.", + ) + } + if (purpose === "verify") { + const session = attempt.originatingSessionId + ? await records.sessionById(attempt.originatingSessionId) + : null + const membership = session ? await records.membership(session.userId) : null + if ( + !session || + session.externalIdentity.provider !== "oidc" || + session.externalIdentity.issuer !== oidcConfiguration.issuer || + session.externalIdentity.subject !== profile.subject + ) { + throw new AuthenticationError(403, "identity_mismatch", "Verify the same OIDC identity.") + } + if ( + !profile.role || + membership?.status !== "active" || + (membership.source === "oidc-assignment" && membership.role !== profile.role) + ) { + await records.audit({ + event: "session.verify", + outcome: "denied", + actorUserId: session.userId, + sessionId: session.id, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership?.id ?? null, + context: { reason: "provider_role_changed" }, + }) + return { providerRoleChanged: true as const } + } + await records.verifySession(session.id, now()) + await records.audit({ + event: "session.verify", + outcome: "succeeded", + actorUserId: session.userId, + sessionId: session.id, + authenticationProviderRevisionId: oidcProviderRevisionId, + }) + return { purpose: "verify" as const } + } + const requiredRole = purpose === "bootstrap" || purpose === "recovery" ? "administrator" : null + if (!profile.role || (requiredRole && profile.role !== requiredRole)) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason: "required_role_missing" }, + }) + return { denied: true as const } + } + let identity = await records.externalIdentity("oidc", oidcConfiguration.issuer, profile.subject) + const directoryIdentity = profile.directoryObjectId + ? await records.externalIdentityByDirectoryObjectId( + "oidc", + oidcConfiguration.issuer, + profile.directoryObjectId, + ) + : null + if (!identity && directoryIdentity) { + identity = await records.updateExternalIdentitySubject(directoryIdentity.id, profile.subject) + await records.audit({ + event: "external_identity.subject_migrate", + outcome: "succeeded", + actorUserId: directoryIdentity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { source: "directory_object_id" }, + }) + } + let membership = identity ? await records.membership(identity.userId) : null + const invitationSubject = profile.directoryObjectId + const invitation = + purpose === "sign-in" && + invitationSubject && + membership?.status !== "active" && + membership?.status !== "suspended" + ? await records.usableInvitation("oidc", oidcConfiguration.issuer, invitationSubject, now()) + : null + if ( + purpose === "sign-in" && + (membership?.status === "suspended" || + (membership?.status === "removed" && !invitation) || + (!membership && + oidcConfiguration.membershipPolicy === "invitation-required" && + !invitation)) + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership?.id ?? null, + context: { + reason: + membership?.status ?? + (invitationSubject ? "invitation_required" : "directory_object_id_missing"), + }, + }) + return { denied: true as const } + } + if ( + purpose === "sign-in" && + invitation?.role === "administrator" && + profile.role !== "administrator" + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership?.id ?? null, + invitationId: invitation.id, + context: { reason: "administrator_role_required" }, + }) + return { denied: true as const } + } + if (purpose === "bootstrap") { + const completed = + (await records.activeAdministratorCount()) === 0 && (await records.completeBootstrap(now())) + if (!completed) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason: "unavailable" }, + }) + return { denied: true as const } + } + } else if (purpose === "recovery" && (await records.activeAdministratorCount()) > 0) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: identity?.userId ?? null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason: "active_administrator_exists" }, + }) + return { denied: true as const } + } + if (!identity) { + const user = await records.createUser({ + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: oidcConfiguration.issuer, + subject: profile.subject, + directoryObjectId: profile.directoryObjectId, + username: profile.username, + email: profile.email, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } else { + await records.updateUser(identity.userId, { + displayName: profile.displayName ?? profile.username, + avatarUrl: profile.avatarUrl, + }) + identity = await records.saveExternalIdentity({ + userId: identity.userId, + provider: "oidc", + issuer: oidcConfiguration.issuer, + subject: profile.subject, + directoryObjectId: profile.directoryObjectId, + username: profile.username, + email: profile.email, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + }) + } + membership = await records.membership(identity.userId) + if (purpose === "bootstrap" || purpose === "recovery") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "administrator", + source: purpose, + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: `membership.${purpose}`, + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + }) + } else if (invitation && membership?.status !== "suspended") { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: invitation.role, + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + const consumed = await records.consumeInvitation( + invitation.id, + identity.id, + membership.id, + now(), + ) + if (!consumed) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation changed. Start again.", + ) + } + await records.audit({ + event: "membership.invitation.activate", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + invitationId: invitation.id, + }) + } else if (!membership) { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: profile.role, + source: "oidc-assignment", + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: "membership.oidc_assignment.activate", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + context: { reason: `role_${profile.role}` }, + }) + } else if ( + membership.status === "active" && + membership.source === "oidc-assignment" && + membership.role !== profile.role + ) { + if ( + membership.role === "administrator" && + profile.role === "member" && + (await records.activeAdministratorCount()) <= 1 + ) { + await records.audit({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + context: { reason: "last_administrator" }, + }) + return { denied: true as const } + } + membership = await records.saveMembership({ + userId: membership.userId, + status: membership.status, + role: profile.role, + source: membership.source, + suspendedAt: membership.suspendedAt, + removedAt: membership.removedAt, + }) + await records.audit({ + event: "membership.provider_role.refresh", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + targetMembershipId: membership.id, + context: { reason: `role_${profile.role}` }, + }) + } + if (!membership || membership.status !== "active") return { denied: true as const } + const sessionToken = randomBytes(32).toString("base64url") + const session = await records.createSession({ + userId: identity.userId, + externalIdentityId: identity.id, + authenticationProviderRevisionId: oidcProviderRevisionId, + tokenDigest: digestSecret(sessionToken), + signedInAt: now(), + freshAuthorizedAt: null, + }) + await records.audit({ + event: "session.sign_in", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: session.id, + authenticationProviderRevisionId: oidcProviderRevisionId, + }) + return { sessionToken, purpose, denied: false as const } + }) + if ("denied" in result && result.denied) { + throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + } + if ("providerRoleChanged" in result && result.providerRoleChanged) { + throw new AuthenticationError( + 403, + "provider_role_changed", + "Your provider role changed. Sign in again.", + ) + } + return result +} diff --git a/hub/server/domains/authentication/create-admission-policy/index.ts b/hub/server/domains/authentication/create-admission-policy/index.ts new file mode 100644 index 00000000..f1aae2d8 --- /dev/null +++ b/hub/server/domains/authentication/create-admission-policy/index.ts @@ -0,0 +1,54 @@ +import type { AdmissionPolicy } from "#server/domains/authentication/create-service/types" +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" +import { checkRepositoryAdmission } from "#server/integrations/github/check-repository-admission" +import { AuthenticationError } from "#server/utils/authentication-error" + +export function createAdmissionPolicy( + environment: NodeJS.ProcessEnv, + store: IdentityStore, + githubAuthorization: GitHubAuthorizationService, +): AdmissionPolicy { + const selected = environment.HUB_ADMISSION_POLICY ?? "hub-managed" + if (selected === "hub-managed") { + return { name: "hub-managed", admit: () => Promise.resolve(null) } + } + if (selected === "github-synchronized") { + return { + name: "github-synchronized", + admit: (profile) => githubAuthorization.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + githubAuthorization.saveAdmissionBaseline(database, userId, baseline), + } + } + if (selected !== "legacy-repository") { + throw new Error( + "HUB_ADMISSION_POLICY must be hub-managed, github-synchronized, or legacy-repository.", + ) + } + const provider = createGitHubIntegrationCredentialProvider(environment) + return { + name: "legacy-repository", + admit: async (profile) => { + try { + const credentials = provider.read(await store.read.githubIntegrationState()) + if (!credentials) { + throw new AuthenticationError( + 503, + "source_integration_unconfigured", + "GitHub source integration is not configured.", + ) + } + return (await checkRepositoryAdmission(credentials, profile)) ? "legacy" : null + } catch (error) { + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + }, + } +} diff --git a/hub/server/domains/authentication/create-runtime/index.test.ts b/hub/server/domains/authentication/create-runtime/index.test.ts new file mode 100644 index 00000000..d13c236f --- /dev/null +++ b/hub/server/domains/authentication/create-runtime/index.test.ts @@ -0,0 +1,103 @@ +import os from "node:os" +import path from "node:path" + +import { describe, expect, it, vi } from "vitest" + +const { createLocalArtifactStorage, ensureOidcProviderRevision } = vi.hoisted(() => ({ + createLocalArtifactStorage: vi.fn((root: string) => { + if (!path.isAbsolute(root)) { + throw new Error("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") + } + return { + put: vi.fn(), + read: vi.fn(), + remove: vi.fn(), + } + }), + ensureOidcProviderRevision: vi.fn(), +})) + +vi.mock("#server/integrations/artifact-storage/create-local-storage", () => ({ + createLocalArtifactStorage, +})) +vi.mock("#server/domains/authentication/ensure-oidc-provider-revision", () => ({ + ensureOidcProviderRevision, +})) +vi.mock("#server/integrations/prisma", () => ({ prisma: {} })) + +import { createAuthenticationRuntime } from "#server/domains/authentication/create-runtime" + +function oidcEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + HUB_AUTH_PROVIDER: "oidc", + HUB_OIDC_ENABLED: "true", + HUB_OIDC_ISSUER: "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + HUB_OIDC_CLIENT_ID: "hub-client", + HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE: "HUB_OIDC_CLIENT_SECRET", + HUB_OIDC_CLIENT_SECRET: "client-secret-fixture", + HUB_OIDC_CALLBACK_URL: "http://127.0.0.1:5173/api/auth/oidc/callback", + HUB_OIDC_SCOPES: "openid profile email", + HUB_OIDC_MEMBER_ROLE: "Hub.User", + HUB_OIDC_ADMINISTRATOR_ROLE: "Hub.Administrator", + HUB_OIDC_MEMBERSHIP_POLICY: "invitation-required", + HUB_OIDC_PROVIDER_REVISION: "5", + ...overrides, + } +} + +describe("createAuthenticationRuntime()", () => { + it.each([undefined, "", " \t "])( + "keeps unset artifact storage outside the checkout for %j", + async (configuredPath) => { + await createAuthenticationRuntime({ + HUB_AUTH_PROVIDER: "github", + ...(configuredPath === undefined ? {} : { HUB_ARTIFACT_STORAGE_PATH: configuredPath }), + }) + + expect(createLocalArtifactStorage).toHaveBeenCalledWith( + path.join(os.homedir(), ".replica", "artifacts"), + ) + const storagePath = createLocalArtifactStorage.mock.calls[0]?.[0] + expect(storagePath).toBeTypeOf("string") + if (!storagePath) throw new Error("Expected an artifact storage path") + expect(path.relative(process.cwd(), storagePath)).toMatch(/^\.\./) + }, + ) + + it("passes a configured relative storage path unchanged to the storage adapter", async () => { + await expect( + createAuthenticationRuntime({ + HUB_AUTH_PROVIDER: "github", + HUB_ARTIFACT_STORAGE_PATH: "relative/artifacts", + }), + ).rejects.toThrow("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") + + expect(createLocalArtifactStorage).toHaveBeenCalledWith("relative/artifacts") + }) + + it("rejects an incompatible OIDC admission policy before persisting a provider revision", async () => { + await expect( + createAuthenticationRuntime({ + HUB_AUTH_PROVIDER: "oidc", + HUB_ADMISSION_POLICY: "github-synchronized", + }), + ).rejects.toThrow("OIDC authentication requires HUB_ADMISSION_POLICY=hub-managed.") + + expect(ensureOidcProviderRevision).not.toHaveBeenCalled() + }) + + it("rejects invalid host secrets before persisting a replacement OIDC provider revision", async () => { + const sharedSecret = "shared-host-secret-fixture-value" + + await expect( + createAuthenticationRuntime( + oidcEnvironment({ + HUB_BOOTSTRAP_SECRET: sharedSecret, + HUB_ADMIN_RECOVERY_SECRET: sharedSecret, + }), + ), + ).rejects.toThrow("HUB_BOOTSTRAP_SECRET and HUB_ADMIN_RECOVERY_SECRET must be different.") + + expect(ensureOidcProviderRevision).not.toHaveBeenCalled() + }) +}) diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts new file mode 100644 index 00000000..5094b90e --- /dev/null +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -0,0 +1,151 @@ +import path from "node:path" +import os from "node:os" + +import { createAdmissionPolicy } from "#server/domains/authentication/create-admission-policy" +import { createArtifactPublishingService } from "#server/domains/artifact-publishing/create-service" +import { createAdministrationService } from "#server/domains/administration/create-service" +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { AuthenticationSettings } from "#server/domains/authentication/create-service/types" +import { ensureProviderAdmissionPolicy } from "#server/domains/authentication/ensure-provider-admission-policy" +import { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" +import { ensureOidcProviderRevision } from "#server/domains/authentication/ensure-oidc-provider-revision" +import { readAuthenticationProvider } from "#server/domains/authentication/read-authentication-provider" +import { readOidcConfiguration } from "#server/domains/authentication/read-oidc-configuration" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { createPrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service" +import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" +import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" +import { createLocalArtifactStorage } from "#server/integrations/artifact-storage/create-local-storage" +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { createOidcClient } from "#server/integrations/oidc/create-client" +import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" +import { prisma } from "#server/integrations/prisma" + +function readOrigin(environment: NodeJS.ProcessEnv): URL { + const origin = new URL(environment.HUB_ORIGIN ?? "http://127.0.0.1:5173") + const secure = origin.protocol === "https:" + if ( + !["https:", "http:"].includes(origin.protocol) || + origin.origin !== origin.href.replace(/\/$/, "") || + origin.username || + origin.password || + (!secure && + (environment.NODE_ENV === "production" || + !["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname))) + ) { + throw new Error( + "Set HUB_ORIGIN to an HTTPS origin, or a loopback HTTP origin outside production.", + ) + } + return origin +} + +function readGitHubAuthentication( + environment: NodeJS.ProcessEnv, + origin: URL, +): GitHubUserAuthConfiguration | null { + const clientId = environment.HUB_GITHUB_AUTH_CLIENT_ID + const clientSecret = environment.HUB_GITHUB_AUTH_CLIENT_SECRET + if (!clientId && !clientSecret) return null + if ( + !clientId || + !clientSecret || + clientId.length > 255 || + clientSecret.length < 20 || + clientSecret.length > 1024 + ) { + throw new Error("Set both HUB_GITHUB_AUTH_CLIENT_ID and HUB_GITHUB_AUTH_CLIENT_SECRET.") + } + return { + clientId, + clientSecret, + callbackUrl: `${origin.origin}/api/auth/github/callback`, + issuer: "https://github.com", + } +} + +function readHostSecret(environment: NodeJS.ProcessEnv, key: string): string | null { + const value = environment[key] + if (!value) return null + if (value.length < 32 || value.length > 1_024) { + throw new Error(`${key} must contain between 32 and 1024 characters.`) + } + return value +} + +export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv) { + const origin = readOrigin(environment) + const provider = readAuthenticationProvider(environment) + ensureProviderAdmissionPolicy(provider, environment) + const configuration = provider === "github" ? readGitHubAuthentication(environment, origin) : null + const oidcConfiguration = provider === "oidc" ? readOidcConfiguration(environment, origin) : null + const store = createIdentityStore(prisma) + const githubAuthorization = createGitHubAuthorizationService({ + database: prisma, + environment, + store, + }) + const admissionPolicy = createAdmissionPolicy(environment, store, githubAuthorization) + const bootstrapSecret = readHostSecret(environment, "HUB_BOOTSTRAP_SECRET") + const recoverySecret = readHostSecret(environment, "HUB_ADMIN_RECOVERY_SECRET") + if (bootstrapSecret && recoverySecret && bootstrapSecret === recoverySecret) { + throw new Error("HUB_BOOTSTRAP_SECRET and HUB_ADMIN_RECOVERY_SECRET must be different.") + } + const settings: AuthenticationSettings = { + origin: origin.origin, + secureCookies: origin.protocol === "https:", + configured: provider === "oidc" ? oidcConfiguration?.enabled === true : configuration !== null, + provider: provider === "oidc" ? "oidc" : configuration ? "github" : null, + providerLabel: provider === "oidc" ? "Microsoft Entra ID" : configuration ? "GitHub" : null, + admissionPolicy: admissionPolicy.name, + trustedProxyHops: readTrustedProxyHops(environment), + } + const configuredArtifactStoragePath = environment.HUB_ARTIFACT_STORAGE_PATH?.trim() + const artifactStoragePath = + configuredArtifactStoragePath || path.join(os.homedir(), ".replica", "artifacts") + const administration = createAdministrationService({ store }) + const artifactPublishing = createArtifactPublishingService({ + database: prisma, + storage: createLocalArtifactStorage(artifactStoragePath), + }) + const prototypeAuthorization = createPrototypeAuthorizationService({ + baseline: { + baseline: (database, userId, prototypeId) => + githubAuthorization.prototypeBaseline(database, userId, prototypeId), + workspaceIds: (database, userId) => + githubAuthorization.prototypeWorkspaceIds(database, userId), + prototypeIds: (database, userId, workspaceId) => + githubAuthorization.prototypeIds(database, userId, workspaceId), + }, + database: prisma, + store, + }) + const github = configuration ? createGitHubUserAuthClient(configuration) : null + const oidc = oidcConfiguration ? createOidcClient(oidcConfiguration) : null + const providerRevision = configuration + ? await ensureGitHubProviderRevision(store, configuration) + : null + const oidcProviderRevision = oidcConfiguration + ? await ensureOidcProviderRevision(store, oidcConfiguration) + : null + return { + administration, + artifactPublishing, + githubAuthorization, + prototypeAuthorization, + service: createAuthenticationService({ + store, + github, + configuration, + providerRevisionId: providerRevision?.id ?? null, + oidc, + oidcConfiguration, + oidcProviderRevisionId: oidcProviderRevision?.id ?? null, + provider, + admissionPolicy, + bootstrapSecret, + recoverySecret, + }), + settings, + } +} diff --git a/hub/server/domains/authentication/create-service/index.db.test.ts b/hub/server/domains/authentication/create-service/index.db.test.ts new file mode 100644 index 00000000..33ee1531 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -0,0 +1,685 @@ +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createAdministrationService } from "#server/domains/administration/create-service" +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import { createIdentityStore } from "#server/domains/identity/create-store" +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +type IdentityStore = ReturnType + +const oidcIssuer = "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0" +const oidcConfiguration: OidcConfiguration = { + issuer: oidcIssuer, + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "jit-assignment", + enabled: true, + providerRevision: 4, +} + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { prisma, store: createIdentityStore(prisma) } +} + +async function createOidcMember(store: IdentityStore, role: "member" | "administrator") { + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: "OIDC member", + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: oidcIssuer, + subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + username: "member@example.test", + email: "member@example.test", + displayName: "OIDC member", + avatarUrl: null, + }) + const provider = await records.createAuthenticationProviderRevision({ + key: "oidc", + type: "oidc", + enabled: true, + revision: 4, + issuer: oidcIssuer, + configuration: { clientId: "hub-client" }, + }) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role, + source: "oidc-assignment", + }) + return { identity, membership, provider, user } + }) +} + +function oidcService( + store: IdentityStore, + providerRevisionId: string, + role: "member" | "administrator", + subject = "immutable-subject", +) { + return createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + oidc: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject, + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + username: "member@example.test", + email: "member@example.test", + displayName: "OIDC member", + avatarUrl: null, + role, + }), + }, + oidcConfiguration, + oidcProviderRevisionId: providerRevisionId, + provider: "oidc", + admissionPolicy: { name: "hub-managed", admit: vi.fn().mockResolvedValue(false) }, + }) +} + +async function createAdministrator(store: IdentityStore) { + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Synthetic administrator", + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "octocat", + displayName: "Synthetic administrator", + avatarUrl: null, + }) + const provider = await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }) + await records.saveMembership({ + userId: user.id, + status: "active", + role: "administrator", + source: "manual", + }) + return { identity, provider, user } + }) +} + +async function createSession( + store: IdentityStore, + graph: Awaited>, + token: string, +) { + const authorizedAt = new Date() + return store.lock((records) => + records.createSession({ + userId: graph.user.id, + externalIdentityId: graph.identity.id, + authenticationProviderRevisionId: graph.provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }), + ) +} + +describe("createAuthenticationService() database behavior", () => { + it("requires a fresh eligible baseline only for synchronized sessions", async () => { + const { prisma, store } = await database() + const graph = await createAdministrator(store) + await createSession(store, graph, "session-token") + await prisma.installationMembership.update({ + where: { userId: graph.user.id }, + data: { role: "member", source: "synchronized" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const baseline = await prisma.gitHubSynchronizedAccess.create({ + data: { + repositorySourceId: source.id, + userId: graph.user.id, + rawPermission: "read", + mappedPrototypeRole: "viewer", + admissionEligible: true, + lastConfirmedAt: new Date(), + }, + }) + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(null) }, + }) + + await expect(service.current("session-token")).resolves.toMatchObject({ + userId: graph.user.id, + membership: { source: "synchronized", status: "active" }, + }) + + await prisma.gitHubSynchronizedAccess.update({ + where: { id: baseline.id }, + data: { + admissionEligible: false, + mappedPrototypeRole: null, + rawPermission: "none", + status: "removed", + }, + }) + + await expect(service.current("session-token")).rejects.toMatchObject({ + status: 401, + code: "membership_inactive", + }) + + await prisma.installationMembership.update({ + where: { userId: graph.user.id }, + data: { source: "manual" }, + }) + await expect(service.current("session-token")).resolves.toMatchObject({ + userId: graph.user.id, + membership: { source: "manual", status: "active" }, + }) + }) + + it.each(["suspended", "removed"] as const)( + "does not reactivate a manually %s membership when synchronized admission succeeds", + async (status) => { + const { prisma, store } = await database() + const graph = await createAdministrator(store) + const now = new Date() + await store.lock(async (records) => { + await records.saveMembership({ + userId: graph.user.id, + status, + role: "member", + source: "manual", + suspendedAt: status === "suspended" ? now : null, + removedAt: status === "removed" ? now : null, + }) + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(now.getTime() + 60_000), + }) + }) + const saveBaseline = vi.fn() + const service = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: graph.identity.subject, + username: "octocat", + displayName: "Synthetic administrator", + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: graph.provider.id, + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + subject: graph.identity.subject, + admissionGeneration: 4, + persist: true, + rawPermission: "read", + admissionEligible: true, + prototypeRole: "viewer", + confirmedAt: now, + }), + saveBaseline, + }, + now: () => now, + }) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect( + await prisma.installationMembership.findUnique({ where: { userId: graph.user.id } }), + ).toMatchObject({ source: "manual", status }) + expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(0) + expect(saveBaseline).not.toHaveBeenCalled() + }, + ) + + it("commits an admission-policy failure audit before returning the provider error", async () => { + const { prisma, store } = await database() + const graph = await createAdministrator(store) + const now = new Date() + await prisma.installationMembership.update({ + where: { userId: graph.user.id }, + data: { role: "member", source: "synchronized" }, + }) + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(now.getTime() + 60_000), + }), + ) + const service = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: graph.identity.subject, + username: "octocat", + displayName: "Synthetic administrator", + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: graph.provider.id, + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockRejectedValue( + Object.assign(new Error("GitHub authorization changed."), { + status: 409, + code: "github_authorization_changed", + }), + ), + }, + now: () => now, + }) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + expect( + await prisma.securityAudit.findFirst({ + where: { event: "authentication.admission_failed" }, + }), + ).toMatchObject({ + outcome: "failed", + actorUserId: graph.user.id, + authenticationProviderRevisionId: graph.provider.id, + context: { reason: "source_integration_unavailable" }, + }) + expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(0) + }) + + it("rolls back generation, session deletion, and audit together when revocation fails", async () => { + const { store } = await database() + const graph = await createAdministrator(store) + const session = await createSession(store, graph, "rollback-token") + + await expect( + store.lock(async (records) => { + await records.advanceSessionGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + actorUserId: graph.user.id, + sessionId: session.id, + authenticationProviderRevisionId: graph.provider.id, + }) + throw new Error("revocation failed") + }), + ).rejects.toThrow("revocation failed") + + expect(await store.read.identityStoreState()).toEqual({ sessionGeneration: 0 }) + expect(await store.read.session(digestSecret("rollback-token"))).toMatchObject({ + id: session.id, + }) + expect(await store.read.audits()).toEqual([]) + }) + + it("serializes concurrent revoke-all requests so generation advances exactly once", async () => { + const { store } = await database() + const graph = await createAdministrator(store) + await createSession(store, graph, "administrator-token") + await createSession(store, graph, "other-token") + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve("legacy") }, + }) + + const results = await Promise.allSettled([ + service.revokeAll("administrator-token"), + service.revokeAll("administrator-token"), + ]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + expect(await store.read.identityStoreState()).toEqual({ sessionGeneration: 1 }) + expect(await store.read.session(digestSecret("administrator-token"))).toBeNull() + expect(await store.read.session(digestSecret("other-token"))).toBeNull() + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "session.revoke_all", + actorUserId: graph.user.id, + }), + ]) + }) + + it("rolls back a new OIDC assignment when its activation audit cannot be written", async () => { + const { prisma, store } = await database() + const provider = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "oidc", + type: "oidc", + enabled: true, + revision: 4, + issuer: oidcIssuer, + configuration: { clientId: "hub-client" }, + }), + ) + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider.id, + originatingSessionId: null, + stateDigest: digestSecret("new-assignment-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "nonce", + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + + await prisma.$executeRawUnsafe(` + ALTER TABLE "security_audits" + ADD CONSTRAINT "test_reject_oidc_assignment_activation" + CHECK ("event" <> 'membership.oidc_assignment.activate') + `) + try { + await expect( + oidcService(store, provider.id, "member").complete( + "provider-code", + "new-assignment-state", + "browser-token", + ), + ).rejects.toThrow() + + expect(await prisma.user.count()).toBe(0) + expect(await prisma.externalIdentity.count()).toBe(0) + expect(await prisma.installationMembership.count()).toBe(0) + expect(await prisma.session.count()).toBe(0) + expect(await store.read.audits()).toEqual([]) + } finally { + await prisma.$executeRawUnsafe(` + ALTER TABLE "security_audits" + DROP CONSTRAINT IF EXISTS "test_reject_oidc_assignment_activation" + `) + } + }) + + it("commits a verification role-change denial before returning the error", async () => { + const { prisma, store } = await database() + const graph = await createOidcMember(store, "administrator") + const signedInAt = new Date() + const session = await store.lock((records) => + records.createSession({ + userId: graph.user.id, + externalIdentityId: graph.identity.id, + authenticationProviderRevisionId: graph.provider.id, + tokenDigest: digestSecret("session-token"), + signedInAt, + freshAuthorizedAt: null, + }), + ) + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: session.id, + stateDigest: digestSecret("verify-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "nonce", + purpose: "verify", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + + await expect( + oidcService(store, graph.provider.id, "member").complete( + "provider-code", + "verify-state", + "browser-token", + ), + ).rejects.toMatchObject({ status: 403, code: "provider_role_changed" }) + + expect(await prisma.session.findUnique({ where: { id: session.id } })).toMatchObject({ + freshAuthorizedAt: null, + }) + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "session.verify", + outcome: "denied", + actorUserId: graph.user.id, + sessionId: session.id, + targetMembershipId: graph.membership.id, + context: { reason: "provider_role_changed" }, + }), + ]) + }) + + it("keeps a new OIDC administrator session non-fresh until verification permits a mutation", async () => { + const { prisma, store } = await database() + const graph = await createOidcMember(store, "administrator") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("sign-in-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "sign-in-nonce", + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const authentication = oidcService(store, graph.provider.id, "administrator") + const signedIn = await authentication.complete( + "provider-code", + "sign-in-state", + "browser-token", + ) + const session = await prisma.session.findUniqueOrThrow({ + where: { tokenDigest: digestSecret(signedIn.sessionToken!) }, + }) + const administration = createAdministrationService({ store }) + const invitation = { + provider: "oidc", + issuer: oidcIssuer, + subject: "b1e2d3c4-a5b6-4789-abcd-ef0123456789", + role: "member" as const, + } + + expect(session.freshAuthorizedAt).toBeNull() + await expect(administration.invite(signedIn.sessionToken!, invitation)).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: session.id, + stateDigest: digestSecret("verify-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "verify-nonce", + purpose: "verify", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + await expect( + authentication.complete("provider-code", "verify-state", "browser-token"), + ).resolves.toEqual({ purpose: "verify" }) + + const verifiedSession = await prisma.session.findUniqueOrThrow({ where: { id: session.id } }) + expect(verifiedSession.freshAuthorizedAt).toBeInstanceOf(Date) + await expect(administration.invite(signedIn.sessionToken!, invitation)).resolves.toMatchObject( + invitation, + ) + }) + + it("migrates a changed provider subject without duplicating the existing identity graph", async () => { + const { prisma, store } = await database() + const graph = await createOidcMember(store, "member") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("changed-subject-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "nonce", + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + + const result = await oidcService( + store, + graph.provider.id, + "member", + "replacement-subject", + ).complete("provider-code", "changed-subject-state", "browser-token") + + expect(result.purpose).toBe("sign-in") + expect(result.sessionToken).toEqual(expect.any(String)) + expect(await prisma.user.count()).toBe(1) + expect(await prisma.externalIdentity.count()).toBe(1) + expect(await prisma.installationMembership.count()).toBe(1) + expect( + await prisma.externalIdentity.findUnique({ where: { id: graph.identity.id } }), + ).toMatchObject({ + userId: graph.user.id, + subject: "replacement-subject", + directoryObjectId: graph.identity.directoryObjectId, + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: graph.membership.id } }), + ).toMatchObject({ userId: graph.user.id, status: "active", role: "member" }) + expect(await prisma.session.findFirst({ where: { userId: graph.user.id } })).toMatchObject({ + externalIdentityId: graph.identity.id, + authenticationProviderRevisionId: graph.provider.id, + }) + expect(await store.read.audits()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "external_identity.subject_migrate", + outcome: "succeeded", + actorUserId: graph.user.id, + context: { source: "directory_object_id" }, + }), + expect.objectContaining({ + event: "session.sign_in", + outcome: "succeeded", + actorUserId: graph.user.id, + }), + ]), + ) + }) + + it("commits a provider role refresh and its supported audit context together", async () => { + const { prisma, store } = await database() + const graph = await createOidcMember(store, "member") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: graph.provider.id, + originatingSessionId: null, + stateDigest: digestSecret("sign-in-state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "nonce", + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + + await expect( + oidcService(store, graph.provider.id, "administrator").complete( + "provider-code", + "sign-in-state", + "browser-token", + ), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect( + await prisma.installationMembership.findUnique({ where: { id: graph.membership.id } }), + ).toMatchObject({ role: "administrator", source: "oidc-assignment" }) + expect(await prisma.session.count({ where: { userId: graph.user.id } })).toBe(1) + const audits = await store.read.audits() + expect(audits).toHaveLength(2) + expect(audits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: "membership.provider_role.refresh", + outcome: "succeeded", + actorUserId: graph.user.id, + targetMembershipId: graph.membership.id, + context: { reason: "role_administrator" }, + }), + expect.objectContaining({ + event: "session.sign_in", + outcome: "succeeded", + actorUserId: graph.user.id, + }), + ]), + ) + }) +}) diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts new file mode 100644 index 00000000..62f77d19 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -0,0 +1,898 @@ +import { describe, expect, it, vi } from "vitest" +import type { Mocked } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" + +const now = new Date("2026-10-27T18:00:00.000Z") +const configuration = { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com" as const, +} +const providerRevision = { + id: "provider-revision", + authenticationProviderId: "provider", + revision: 1, + issuer: configuration.issuer, + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, +} + +type MockIdentityRecords = Mocked + +function records(overrides: Partial = {}) { + const fixture = { + currentAuthenticationProviderRevision: vi.fn().mockResolvedValue(providerRevision), + identityStoreState: vi.fn().mockResolvedValue({ sessionGeneration: 3 }), + createAuthorizationAttempt: vi.fn().mockResolvedValue(undefined), + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(null), + externalIdentity: vi.fn().mockResolvedValue(null), + createUser: vi.fn(), + updateUser: vi.fn(), + saveExternalIdentity: vi.fn(), + createSession: vi.fn(), + verifySession: vi.fn(), + deleteSession: vi.fn(), + deleteSessions: vi.fn(), + advanceSessionGeneration: vi.fn(), + session: vi.fn().mockResolvedValue(null), + sessionById: vi.fn().mockResolvedValue(null), + membership: vi.fn().mockResolvedValue(null), + usableInvitation: vi.fn().mockResolvedValue(null), + consumeInvitation: vi.fn().mockResolvedValue(true), + bootstrapCompletedAt: vi.fn().mockResolvedValue(null), + activeAdministratorCount: vi.fn().mockResolvedValue(0), + completeBootstrap: vi.fn().mockResolvedValue(true), + saveMembership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }), + audit: vi.fn(), + } + return Object.assign(fixture, overrides) +} + +type TestIdentityRecords = ReturnType + +function store(identityRecords: TestIdentityRecords): IdentityStore { + const typedRecords = identityRecords as unknown as IdentityRecords + return { + read: typedRecords, + lock: (operation) => operation(typedRecords, {} as never), + } +} + +function dependencies( + identityRecords: TestIdentityRecords, + overrides: Partial< + Pick + > = {}, +) { + const github: Mocked = { + authorizationUrl: vi.fn( + (state, verifier) => `https://github.example/authorize?state=${state}&verifier=${verifier}`, + ), + identify: vi.fn(), + } + return { + store: store(identityRecords), + github, + configuration, + providerRevisionId: providerRevision.id, + admissionPolicy: { + name: "hub-managed" as const, + admit: vi.fn().mockResolvedValue(true), + }, + now: () => now, + ...overrides, + } +} + +function attempt(overrides: Record = {}) { + return { + id: "attempt", + authenticationProviderRevisionId: providerRevision.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in" as const, + sessionGeneration: 3, + expiresAt: new Date(now.getTime() + 60_000), + ...overrides, + } +} + +describe("createAuthenticationService()", () => { + it("starts a short-lived browser-bound authorization with state and an S256-ready verifier", async () => { + const identityRecords = records() + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + }) + const service = createAuthenticationService(options) + + const result = await service.start("sign-in") + + const authorizationUrl = new URL(result.authorizationUrl) + const state = authorizationUrl.searchParams.get("state") + const verifier = authorizationUrl.searchParams.get("verifier") + expect(state).toMatch(/^[\w-]{43}$/) + expect(result.browserToken).toMatch(/^[\w-]{43}$/) + expect(verifier).toMatch(/^[\w-]{43}$/) + expect(identityRecords.createAuthorizationAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + stateDigest: digestSecret(state!), + browserDigest: digestSecret(result.browserToken), + pkceVerifier: verifier, + purpose: "sign-in", + sessionGeneration: 3, + expiresAt: new Date(now.getTime() + 10 * 60_000), + }), + ) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalledWith( + expect.objectContaining({ stateDigest: state, browserDigest: result.browserToken }), + ) + }) + + it("consumes an attempt before rejecting the wrong browser so neither replay can exchange a code", async () => { + let available = attempt() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn(() => { + const consumed = available + available = null as never + return Promise.resolve(consumed) + }), + }) + const options = dependencies(identityRecords) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "wrong-browser")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + expect(options.github.identify.mock.calls).toHaveLength(0) + }) + + it("fails closed when the store reports an expired or unknown authorization attempt", async () => { + const identityRecords = records() + const options = dependencies(identityRecords) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + expect(options.github.identify.mock.calls).toHaveLength(0) + }) + + it("updates the existing immutable GitHub identity and creates a session without repository data", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "old-login", + displayName: "Old name", + avatarUrl: null, + user: { id: "user", displayName: "Old name", avatarUrl: null }, + } + const updatedIdentity = { + ...existingIdentity, + username: "new-login", + displayName: "New name", + avatarUrl: "https://avatars.example/42", + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + updateUser: vi.fn().mockResolvedValue(updatedIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(updatedIdentity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + + const options = dependencies(identityRecords) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "new-login", + displayName: "New name", + avatarUrl: "https://avatars.example/42", + }) + const service = createAuthenticationService(options) + + const result = await service.complete("one-use-code", "state", "browser-token") + + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.saveExternalIdentity).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user", subject: "42", username: "new-login" }), + ) + expect(identityRecords.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + externalIdentityId: "identity", + tokenDigest: digestSecret(result.sessionToken!), + }), + ) + expect(JSON.stringify(vi.mocked(identityRecords.createSession).mock.calls)).not.toContain( + "one-use-code", + ) + }) + + it("uses an exact invitation before a qualifying synchronized baseline for an inactive identity", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "administrator" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + usableInvitation: vi.fn().mockResolvedValue(invitation), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + saveMembership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "administrator", + source: "invitation", + suspendedAt: null, + removedAt: null, + }), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const saveBaseline = vi.fn() + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + subject: "42", + admissionGeneration: 4, + persist: true, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline, + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + const result = await createAuthenticationService(options).complete( + "code", + "state", + "browser-token", + ) + expect(result.purpose).toBe("sign-in") + expect(typeof result.sessionToken).toBe("string") + + expect(identityRecords.usableInvitation).toHaveBeenCalledWith( + "github", + configuration.issuer, + "42", + now, + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + role: "administrator", + source: "invitation", + }), + ) + expect(saveBaseline).toHaveBeenCalledWith(expect.anything(), "user", expect.any(Object)) + }) + + it("uses an invitation for a new identity when synchronized admission is unavailable", async () => { + const invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "administrator" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const identity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation), + createUser: vi.fn().mockResolvedValue(identity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(identity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const saveBaseline = vi.fn() + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ), + ), + saveBaseline, + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + role: "administrator", + source: "invitation", + }), + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + expect(saveBaseline).not.toHaveBeenCalled() + }) + + it.each(["manual", "invitation"] as const)( + "keeps an active %s member signed in when only the baseline save was superseded", + async (source) => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const membership = { + id: "membership", + userId: "user", + status: "active" as const, + role: "member" as const, + source, + suspendedAt: null, + removedAt: null, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + membership: vi.fn().mockResolvedValue(membership), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const saveBaseline = vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed.", + ), + ) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + subject: "42", + admissionGeneration: 4, + persist: true, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline, + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(saveBaseline).toHaveBeenCalledOnce() + expect(identityRecords.createSession).toHaveBeenCalledOnce() + }, + ) + + it("rejects a superseded first synchronized enrollment before creating its session", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const saveMembership = vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + const createSession = vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }) + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + saveMembership, + createSession, + }) + const saveBaseline = vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed.", + ), + ) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + subject: "42", + admissionGeneration: 4, + persist: true, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline, + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + + expect(saveMembership).toHaveBeenCalledOnce() + expect(saveBaseline).toHaveBeenCalledOnce() + expect(saveMembership.mock.invocationCallOrder[0]).toBeLessThan( + saveBaseline.mock.invocationCallOrder[0]!, + ) + expect(createSession).not.toHaveBeenCalled() + }) + + it("propagates a baseline save failure other than a superseded authorization", async () => { + const existingIdentity = { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + user: { id: "user", displayName: "Octocat", avatarUrl: null }, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "manual", + suspendedAt: null, + removedAt: null, + }), + updateUser: vi.fn().mockResolvedValue(existingIdentity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(existingIdentity), + }) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "github-synchronized", + admit: vi.fn().mockResolvedValue({ + repositorySourceId: "00000000-0000-0000-0000-000000000001", + sourceGeneration: 3, + subject: "42", + admissionGeneration: 4, + persist: true, + rawPermission: "write", + admissionEligible: true, + prototypeRole: "editor", + confirmedAt: now, + }), + saveBaseline: vi + .fn() + .mockRejectedValue( + new AuthenticationError(500, "apply_failed", "The baseline could not be saved."), + ), + }, + }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await expect( + createAuthenticationService(options).complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 500, code: "apply_failed" }) + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it("binds bootstrap enrollment to the configured one-use secret", async () => { + const identityRecords = records() + const service = createAuthenticationService({ + ...dependencies(identityRecords), + bootstrapSecret: "bootstrap-secret", + }) + + await expect(service.start("bootstrap", undefined, "wrong-secret")).rejects.toMatchObject({ + status: 404, + code: "bootstrap_unavailable", + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "secret_mismatch" }, + }) + const result = await service.start("bootstrap", undefined, "bootstrap-secret") + expect(result.authorizationUrl).toContain("state=") + expect(typeof result.browserToken).toBe("string") + const [createdAttempt] = identityRecords.createAuthorizationAttempt.mock.calls[0]! + expect(createdAttempt.purpose).toBe("bootstrap") + }) + + it("keeps recovery separate and unavailable while an active administrator remains", async () => { + const identityRecords = records({ + activeAdministratorCount: vi.fn().mockResolvedValue(1), + }) + const service = createAuthenticationService({ + ...dependencies(identityRecords), + recoverySecret: "recovery-secret", + }) + + await expect(service.start("recovery", undefined, "recovery-secret")).rejects.toMatchObject({ + status: 404, + code: "recovery_unavailable", + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "active_administrator_exists" }, + }) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + + it("audits missing recovery proof without recording the submitted request", async () => { + const identityRecords = records() + const service = createAuthenticationService({ + ...dependencies(identityRecords), + recoverySecret: "recovery-secret", + }) + + await expect(service.start("recovery")).rejects.toMatchObject({ + status: 404, + code: "recovery_unavailable", + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason: "secret_missing" }, + }) + expect(JSON.stringify(identityRecords.audit.mock.calls)).not.toContain("recovery-secret") + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + + it("does not turn a failed denied-audit write into a normal enrollment rejection", async () => { + const identityRecords = records({ + audit: vi.fn().mockRejectedValue(new Error("audit failed")), + }) + const service = createAuthenticationService({ + ...dependencies(identityRecords), + bootstrapSecret: "bootstrap-secret", + }) + + await expect(service.start("bootstrap", undefined, "wrong-secret")).rejects.toThrow( + "audit failed", + ) + expect(identityRecords.createAuthorizationAttempt).not.toHaveBeenCalled() + }) + + it("lets a compatibility admission policy deny sign-in before creating identity records", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const admissionPolicy = { + name: "legacy-repository" as const, + admit: vi.fn().mockResolvedValue(false), + } + const options = dependencies(identityRecords, { admissionPolicy }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(admissionPolicy.admit).toHaveBeenCalledWith(expect.objectContaining({ subject: "42" })) + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it("refreshes authorization only when GitHub returns the session's same external identity", async () => { + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "digest", + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "User", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }, + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi + .fn() + .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + sessionById: vi.fn().mockResolvedValue(session), + }) + const options = dependencies(identityRecords) + options.github.identify.mockResolvedValue({ + subject: "99", + username: "different-user", + displayName: null, + avatarUrl: null, + }) + const service = createAuthenticationService(options) + + await expect(service.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "identity_mismatch", + }) + expect(identityRecords.verifySession).not.toHaveBeenCalled() + + options.github.identify.mockResolvedValue({ + subject: "42", + username: "renamed-user", + displayName: null, + avatarUrl: null, + }) + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + await expect(service.complete("code", "state", "browser-token")).resolves.toEqual({ + purpose: "verify", + }) + expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) + }) + + it("signs out only the current session and requires administrator proof strictly younger than five minutes to revoke all", async () => { + const current = { + id: "current-session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: digestSecret("session-token"), + signedInAt: now, + freshAuthorizedAt: new Date(now.getTime() - 5 * 60_000), + user: { id: "user", displayName: "Administrator", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: null, + avatarUrl: null, + }, + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + session: vi.fn().mockResolvedValue(current), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "administrator", + source: "manual", + }), + }) + const service = createAuthenticationService(dependencies(identityRecords)) + + await service.signOut("session-token") + expect(identityRecords.deleteSession).toHaveBeenCalledWith(current.id) + expect(identityRecords.deleteSessions).not.toHaveBeenCalled() + + await expect(service.revokeAll("session-token")).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + expect(identityRecords.advanceSessionGeneration).not.toHaveBeenCalled() + + current.freshAuthorizedAt = new Date(now.getTime() + 1) + await expect(service.revokeAll("session-token")).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + + current.freshAuthorizedAt = new Date(now.getTime() - (5 * 60_000 - 1)) + await service.revokeAll("session-token") + expect(identityRecords.advanceSessionGeneration).toHaveBeenCalledOnce() + expect(identityRecords.deleteSessions).toHaveBeenCalledOnce() + }) +}) diff --git a/hub/server/domains/authentication/create-service/index.ts b/hub/server/domains/authentication/create-service/index.ts new file mode 100644 index 00000000..a6873b07 --- /dev/null +++ b/hub/server/domains/authentication/create-service/index.ts @@ -0,0 +1,33 @@ +import { completeGitHubAuthorization } from "#server/domains/authentication/complete-github-authorization" +import { completeOidcAuthorization } from "#server/domains/authentication/complete-oidc-authorization" +import type { + AuthenticationDependencies, + AuthenticationService, +} from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { revokeSessions } from "#server/domains/authentication/revoke-sessions" +import { signOut } from "#server/domains/authentication/sign-out" +import { startGitHubAuthorization } from "#server/domains/authentication/start-github-authorization" +import { startOidcAuthorization } from "#server/domains/authentication/start-oidc-authorization" + +export function createAuthenticationService( + options: Omit & { now?: () => Date }, +): AuthenticationService { + const dependencies: AuthenticationDependencies = { + ...options, + now: options.now ?? (() => new Date()), + } + return { + start: + dependencies.provider === "oidc" + ? startOidcAuthorization.bind(null, dependencies) + : startGitHubAuthorization.bind(null, dependencies), + complete: + dependencies.provider === "oidc" + ? completeOidcAuthorization.bind(null, dependencies) + : completeGitHubAuthorization.bind(null, dependencies), + current: getCurrentSession.bind(null, dependencies), + signOut: signOut.bind(null, dependencies), + revokeAll: revokeSessions.bind(null, dependencies), + } +} diff --git a/hub/server/domains/authentication/create-service/membership.test.ts b/hub/server/domains/authentication/create-service/membership.test.ts new file mode 100644 index 00000000..ec938477 --- /dev/null +++ b/hub/server/domains/authentication/create-service/membership.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import { digestSecret } from "#server/utils/digest-secret" + +const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: "revision", + tokenDigest: digestSecret("session-token"), + signedInAt: new Date("2026-10-27T18:00:00.000Z"), + freshAuthorizedAt: new Date("2026-10-27T18:00:00.000Z"), + user: { id: "user", displayName: "Member", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "member", + displayName: "Member", + avatarUrl: null, + }, + authenticationProviderRevision: { + id: "revision", + authenticationProviderId: "provider", + revision: 1, + issuer: "https://github.com", + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, + }, +} + +describe("createAuthenticationService() membership enforcement", () => { + it.each(["suspended", "removed"] as const)( + "fails closed when a valid session belongs to a %s membership", + async (status) => { + const records = { + session: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: session.userId, + status, + role: "member", + source: "invitation", + suspendedAt: status === "suspended" ? new Date() : null, + removedAt: status === "removed" ? new Date() : null, + }), + } as unknown as IdentityRecords + const store: IdentityStore = { + read: records, + lock: (operation) => operation(records, {} as never), + } + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { + name: "hub-managed", + admit: () => Promise.resolve(null), + }, + }) + + await expect(service.current("session-token")).rejects.toMatchObject({ + status: 401, + code: "membership_inactive", + }) + }, + ) +}) diff --git a/hub/server/domains/authentication/create-service/oidc.test.ts b/hub/server/domains/authentication/create-service/oidc.test.ts new file mode 100644 index 00000000..87f94743 --- /dev/null +++ b/hub/server/domains/authentication/create-service/oidc.test.ts @@ -0,0 +1,1052 @@ +import { describe, expect, it, vi } from "vitest" +import type { Mocked } from "vitest" + +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { + OidcClient, + OidcConfiguration, + OidcProfile, +} from "#server/integrations/oidc/create-client/types" +import type { ExternalIdentityInput } from "#server/repositories/external-identities/types" +import type { InstallationMembershipInput } from "#server/repositories/installation-memberships/types" +import { digestSecret } from "#server/utils/digest-secret" + +const now = new Date("2026-10-27T18:00:00.000Z") +const issuer = "https://login.example.test/tenant/v2.0" +const configuration: OidcConfiguration = { + issuer, + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "jit-assignment", + enabled: true, + providerRevision: 4, +} +const providerRevision = { + id: "oidc-revision-4", + authenticationProviderId: "oidc-provider", + revision: 4, + issuer, + configuration: {}, + authenticationProvider: { + id: "oidc-provider", + key: "oidc", + type: "oidc", + enabled: true, + }, +} + +type MockIdentityRecords = Mocked + +function records(overrides: Partial = {}) { + return Object.assign( + { + currentAuthenticationProviderRevision: vi.fn().mockResolvedValue(providerRevision), + identityStoreState: vi.fn().mockResolvedValue({ sessionGeneration: 7 }), + createAuthorizationAttempt: vi.fn().mockResolvedValue(undefined), + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(null), + externalIdentity: vi.fn().mockResolvedValue(null), + externalIdentityByDirectoryObjectId: vi.fn().mockResolvedValue(null), + updateExternalIdentitySubject: vi.fn(), + createUser: vi.fn().mockResolvedValue({ + id: "user", + displayName: "Member Name", + avatarUrl: null, + }), + updateUser: vi.fn().mockResolvedValue({ + id: "user", + displayName: "Member Name", + avatarUrl: null, + }), + saveExternalIdentity: vi.fn().mockImplementation((input: ExternalIdentityInput) => + Promise.resolve({ + id: "identity", + ...input, + email: input.email ?? null, + user: { + id: input.userId, + displayName: input.displayName, + avatarUrl: input.avatarUrl, + }, + }), + ), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: null, + }), + verifySession: vi.fn(), + deleteSession: vi.fn(), + deleteSessions: vi.fn(), + advanceSessionGeneration: vi.fn(), + session: vi.fn().mockResolvedValue(null), + sessionById: vi.fn().mockResolvedValue(null), + membership: vi.fn().mockResolvedValue(null), + usableInvitation: vi.fn().mockResolvedValue(null), + consumeInvitation: vi.fn().mockResolvedValue(true), + bootstrapCompletedAt: vi.fn().mockResolvedValue(null), + activeAdministratorCount: vi.fn().mockResolvedValue(0), + completeBootstrap: vi.fn().mockResolvedValue(true), + saveMembership: vi.fn().mockImplementation((input: InstallationMembershipInput) => + Promise.resolve({ + id: "membership", + suspendedAt: null, + removedAt: null, + ...input, + }), + ), + audit: vi.fn(), + }, + overrides, + ) +} + +type TestRecords = ReturnType + +function store(testRecords: TestRecords): IdentityStore { + const typedRecords = testRecords as unknown as IdentityRecords + return { + read: typedRecords, + lock: (operation) => operation(typedRecords, {} as never), + } +} + +function profile(overrides: Partial = {}): OidcProfile { + return { + subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + username: "member@example.test", + email: "member@example.test", + displayName: "Member Name", + avatarUrl: null, + role: "member" as const, + ...overrides, + } +} + +function oidc(overrides: Partial> = {}) { + return Object.assign( + { + authorizationUrl: vi.fn((state, verifier, nonce) => + Promise.resolve( + `https://login.example.test/authorize?state=${state}&verifier=${verifier}&nonce=${nonce}`, + ), + ), + identify: vi.fn().mockResolvedValue(profile()), + }, + overrides, + ) +} + +function attempt(overrides: Record = {}) { + return { + id: "attempt", + authenticationProviderRevisionId: providerRevision.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: "one-use-nonce", + purpose: "sign-in" as const, + sessionGeneration: 7, + expiresAt: new Date(now.getTime() + 60_000), + ...overrides, + } +} + +function service( + testRecords: TestRecords, + client = oidc(), + overrides: Partial = {}, +) { + return { + client, + authentication: createAuthenticationService({ + store: store(testRecords), + github: null, + configuration: null, + providerRevisionId: null, + oidc: client, + oidcConfiguration: { ...configuration, ...overrides }, + oidcProviderRevisionId: providerRevision.id, + provider: "oidc", + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + now: () => now, + }), + } +} + +function activeMembership( + role: "member" | "administrator" = "member", + source: InstallationMembershipInput["source"] = "oidc-assignment", +) { + return { + id: "membership", + userId: "user", + status: "active" as const, + role, + source, + suspendedAt: null, + removedAt: null, + } +} + +function existingIdentity() { + return { + id: "identity", + userId: "user", + provider: "oidc", + issuer, + subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + username: "old-name@example.test", + email: "old-email@example.test", + displayName: "Old Name", + avatarUrl: null, + user: { id: "user", displayName: "Old Name", avatarUrl: null }, + } +} + +function invitation(role: "member" | "administrator" = "member") { + return { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "oidc", + issuer, + subject: "a0e1d2c3-b4a5-4678-9abc-def012345678", + role, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } +} + +describe("createAuthenticationService() OIDC", () => { + it("starts a browser-bound attempt with one-use state, nonce, PKCE, and a ten-minute lifetime", async () => { + const identityRecords = records() + const { authentication, client } = service(identityRecords) + + const result = await authentication.start("sign-in") + + const url = new URL(result.authorizationUrl) + const state = url.searchParams.get("state") + const verifier = url.searchParams.get("verifier") + const nonce = url.searchParams.get("nonce") + expect(state).toMatch(/^[\w-]{43}$/) + expect(result.browserToken).toMatch(/^[\w-]{43}$/) + expect(verifier).toMatch(/^[\w-]{43}$/) + expect(nonce).toMatch(/^[\w-]{43}$/) + expect(client.authorizationUrl).toHaveBeenCalledWith(state, verifier, nonce) + expect(identityRecords.createAuthorizationAttempt).toHaveBeenCalledWith({ + authenticationProviderRevisionId: providerRevision.id, + originatingSessionId: null, + stateDigest: digestSecret(state!), + browserDigest: digestSecret(result.browserToken), + pkceVerifier: verifier, + nonce, + purpose: "sign-in", + sessionGeneration: 7, + expiresAt: new Date(now.getTime() + 10 * 60_000), + }) + }) + + it("forwards fresh-authentication options only for session verification", async () => { + const verificationAttempt = attempt({ + purpose: "verify", + originatingSessionId: "session", + expiresAt: new Date(now.getTime() + 4 * 60_000), + }) + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: digestSecret("session-token"), + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "Member Name", avatarUrl: null }, + externalIdentity: existingIdentity(), + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + session: vi.fn().mockResolvedValue(session), + sessionById: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue(activeMembership()), + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(verificationAttempt), + }) + const { authentication, client } = service(identityRecords) + + await authentication.start("verify", "session-token") + await authentication.complete("code", "state", "browser-token") + + expect(client.authorizationUrl).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + { fresh: true }, + ) + expect(client.identify).toHaveBeenCalledWith("code", "pkce-verifier", "one-use-nonce", { + authenticatedAfter: new Date(verificationAttempt.expiresAt.getTime() - 10 * 60_000), + }) + }) + + it("consumes the saved attempt and audits a provider failure when initiation discovery fails", async () => { + const identityRecords = records() + const client = oidc({ + authorizationUrl: vi.fn().mockRejectedValue(new Error("discovery unavailable")), + }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.start("sign-in")).rejects.toMatchObject({ + status: 503, + code: "authentication_provider_failed", + }) + + const [{ stateDigest }] = identityRecords.createAuthorizationAttempt.mock.calls[0]! + expect(identityRecords.consumeAuthorizationAttempt).toHaveBeenCalledWith(stateDigest) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "authentication.provider_failed", + outcome: "failed", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + }) + }) + + it("consumes the attempt before rejecting the wrong browser, preventing a valid replay", async () => { + let available: ReturnType | null = attempt() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn(() => { + const result = available + available = null + return Promise.resolve(result) + }), + }) + const { authentication, client } = service(identityRecords) + + await expect(authentication.complete("code", "state", "wrong-browser")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 401, + code: "invalid_authorization", + }) + expect(client.identify).not.toHaveBeenCalled() + }) + + it("consumes a long-code attempt once before exchanging it and rejects a replay", async () => { + const longCode = `oidc.${"Ab9_-".repeat(410)}`.slice(0, 2_048) + let available: ReturnType | null = attempt() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn(() => { + const result = available + available = null + return Promise.resolve(result) + }), + }) + const client = oidc() + const { authentication } = service(identityRecords, client) + + const result = await authentication.complete(longCode, "state", "browser-token") + await expect(authentication.complete(longCode, "state", "browser-token")).rejects.toMatchObject( + { + status: 401, + code: "invalid_authorization", + }, + ) + + expect(result.sessionToken).toEqual(expect.any(String)) + expect(identityRecords.consumeAuthorizationAttempt).toHaveBeenCalledTimes(2) + expect(identityRecords.consumeAuthorizationAttempt).toHaveBeenNthCalledWith( + 1, + digestSecret("state"), + ) + expect(client.identify).toHaveBeenCalledOnce() + expect(client.identify).toHaveBeenCalledWith(longCode, "pkce-verifier", "one-use-nonce") + expect(identityRecords.consumeAuthorizationAttempt.mock.invocationCallOrder[0]).toBeLessThan( + client.identify.mock.invocationCallOrder[0]!, + ) + expect( + JSON.stringify([ + identityRecords.createUser.mock.calls, + identityRecords.saveExternalIdentity.mock.calls, + identityRecords.saveMembership.mock.calls, + identityRecords.createSession.mock.calls, + identityRecords.audit.mock.calls, + ]), + ).not.toContain(longCode) + }) + + it.each([ + ["expired", attempt({ expiresAt: now })], + ["old provider revision", attempt({ authenticationProviderRevisionId: "superseded-revision" })], + ])("rejects an %s attempt before exchanging its code", async (_name, savedAttempt) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(savedAttempt), + }) + const { authentication, client } = service(identityRecords) + + await expect( + authentication.complete("provider-code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 401, code: "invalid_authorization" }) + expect(client.identify).not.toHaveBeenCalled() + }) + + it.each([ + [ + "provider changed", + { + currentAuthenticationProviderRevision: () => ({ + ...providerRevision, + id: "new-revision", + }), + sessionGeneration: 7, + }, + ], + [ + "sessions revoked", + { + currentAuthenticationProviderRevision: () => providerRevision, + sessionGeneration: 8, + }, + ], + ])("rejects an exchanged attempt after %s supersedes it", async (_name, scenario) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + currentAuthenticationProviderRevision: vi + .fn() + .mockResolvedValue(scenario.currentAuthenticationProviderRevision()), + identityStoreState: vi + .fn() + .mockResolvedValue({ sessionGeneration: scenario.sessionGeneration }), + }) + const { authentication } = service(identityRecords) + + await expect( + authentication.complete("provider-code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 401, code: "authorization_superseded" }) + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it.each([ + ["no mapped role", null], + ["an unknown role", null], + ])("denies sign-in with %s before persisting identity records", async (_name, role) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const client = oidc({ identify: vi.fn().mockResolvedValue(profile({ role })) }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it.each(["member", "administrator"] as const)( + "maps a new %s claim to an active OIDC-assignment membership", + async (role) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const client = oidc({ identify: vi.fn().mockResolvedValue(profile({ role })) }) + const { authentication } = service(identityRecords, client) + + const result = await authentication.complete("code", "state", "browser-token") + + expect(result.purpose).toBe("sign-in") + expect(result.sessionToken).toEqual(expect.any(String)) + expect(identityRecords.saveMembership).toHaveBeenCalledWith({ + userId: "user", + status: "active", + role, + source: "oidc-assignment", + suspendedAt: null, + removedAt: null, + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.oidc_assignment.activate", + outcome: "succeeded", + actorUserId: "user", + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: "membership", + context: { reason: `role_${role}` }, + }) + }, + ) + + it("reuses the immutable subject while refreshing mutable username, email, and name", async () => { + const identity = existingIdentity() + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(activeMembership()), + }) + const client = oidc({ + identify: vi.fn().mockResolvedValue( + profile({ + username: "renamed@example.test", + email: "new-email@example.test", + displayName: "New Name", + }), + ), + }) + const { authentication } = service(identityRecords, client) + + await authentication.complete("code", "state", "browser-token") + + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.updateUser).toHaveBeenCalledWith("user", { + displayName: "New Name", + avatarUrl: null, + }) + expect(identityRecords.saveExternalIdentity).toHaveBeenCalledWith({ + userId: "user", + provider: "oidc", + issuer, + subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + username: "renamed@example.test", + email: "new-email@example.test", + displayName: "New Name", + avatarUrl: null, + }) + }) + + it("migrates a changed subject through the signed directory object ID and retains the member", async () => { + const identity = existingIdentity() + const membership = activeMembership() + const migratedIdentity = { ...identity, subject: "replacement-subject" } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentityByDirectoryObjectId: vi.fn().mockResolvedValue(identity), + updateExternalIdentitySubject: vi.fn().mockResolvedValue(migratedIdentity), + membership: vi.fn().mockResolvedValue(membership), + }) + const client = oidc({ + identify: vi.fn().mockResolvedValue(profile({ subject: "replacement-subject" })), + }) + const { authentication } = service(identityRecords, client) + + const result = await authentication.complete("code", "state", "browser-token") + + expect(result.purpose).toBe("sign-in") + expect(result.sessionToken).toEqual(expect.any(String)) + expect(identityRecords.updateExternalIdentitySubject).toHaveBeenCalledWith( + identity.id, + "replacement-subject", + ) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "external_identity.subject_migrate", + outcome: "succeeded", + actorUserId: "user", + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { source: "directory_object_id" }, + }) + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: identity.userId, + externalIdentityId: identity.id, + authenticationProviderRevisionId: providerRevision.id, + }), + ) + }) + + it("refreshes authorization only when the active membership still matches the signed provider role", async () => { + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "digest", + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "Member Name", avatarUrl: null }, + externalIdentity: existingIdentity(), + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi + .fn() + .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + sessionById: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue(activeMembership("administrator")), + }) + const client = oidc({ identify: vi.fn().mockResolvedValue(profile({ role: "member" })) }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "provider_role_changed", + }) + expect(identityRecords.verifySession).not.toHaveBeenCalled() + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "session.verify", + outcome: "denied", + actorUserId: "user", + sessionId: "session", + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: "membership", + context: { reason: "provider_role_changed" }, + }) + + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + client.identify.mockResolvedValueOnce(profile({ role: null })) + identityRecords.membership.mockResolvedValueOnce(activeMembership("member")) + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "provider_role_changed", + }) + + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + client.identify.mockResolvedValueOnce(profile({ role: "member" })) + identityRecords.membership.mockResolvedValueOnce({ + ...activeMembership("member"), + status: "suspended", + suspendedAt: now, + }) + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "provider_role_changed", + }) + + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + identityRecords.membership.mockResolvedValueOnce(activeMembership("member")) + await expect(authentication.complete("code", "state", "browser-token")).resolves.toEqual({ + purpose: "verify", + }) + expect(identityRecords.verifySession).toHaveBeenCalledWith(session.id, now) + }) + + it.each([ + ["invitation", "administrator", "member"], + ["invitation", "member", "administrator"], + ["manual", "administrator", "member"], + ["bootstrap", "administrator", "member"], + ["recovery", "administrator", "member"], + ] as const)( + "preserves a %s %s role override when the provider grants %s", + async (source, membershipRole, providerRole) => { + const membership = activeMembership(membershipRole, source) + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), + membership: vi.fn().mockResolvedValue(membership), + }) + const { authentication } = service( + identityRecords, + oidc({ identify: vi.fn().mockResolvedValue(profile({ role: providerRole })) }), + ) + + await expect( + authentication.complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.createSession).toHaveBeenCalledOnce() + }, + ) + + it.each(["invitation", "manual", "bootstrap", "recovery"] as const)( + "verifies a %s role override while requiring an admitted provider role", + async (source) => { + const session = { + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "digest", + signedInAt: now, + freshAuthorizedAt: null, + user: { id: "user", displayName: "Member Name", avatarUrl: null }, + externalIdentity: existingIdentity(), + authenticationProviderRevision: providerRevision, + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi + .fn() + .mockResolvedValue(attempt({ purpose: "verify", originatingSessionId: session.id })), + sessionById: vi.fn().mockResolvedValue(session), + membership: vi.fn().mockResolvedValue(activeMembership("administrator", source)), + }) + const client = oidc({ identify: vi.fn().mockResolvedValue(profile({ role: "member" })) }) + const { authentication } = service(identityRecords, client) + + await expect(authentication.complete("code", "state", "browser-token")).resolves.toEqual({ + purpose: "verify", + }) + + identityRecords.consumeAuthorizationAttempt.mockResolvedValueOnce( + attempt({ purpose: "verify", originatingSessionId: session.id }), + ) + client.identify.mockResolvedValueOnce(profile({ role: null })) + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject( + { + status: 403, + code: "provider_role_changed", + }, + ) + expect(identityRecords.verifySession).toHaveBeenCalledOnce() + }, + ) + + it("refuses a role refresh that would remove the final active administrator", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), + membership: vi.fn().mockResolvedValue(activeMembership("administrator")), + activeAdministratorCount: vi.fn().mockResolvedValue(1), + }) + const { authentication } = service(identityRecords) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + expect(identityRecords.audit).toHaveBeenCalledWith( + expect.objectContaining({ context: { reason: "last_administrator" } }), + ) + }) + + it.each([ + ["member", "administrator", "role_administrator"], + ["administrator", "member", "role_member"], + ] as const)( + "refreshes an active %s to %s and audits the supported reason", + async (currentRole, providerRole, reason) => { + const membership = activeMembership(currentRole) + const refreshedMembership = { ...membership, role: providerRole } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), + membership: vi.fn().mockResolvedValueOnce(membership).mockResolvedValueOnce(membership), + activeAdministratorCount: vi.fn().mockResolvedValue(2), + saveMembership: vi.fn().mockResolvedValue(refreshedMembership), + }) + const { authentication } = service( + identityRecords, + oidc({ identify: vi.fn().mockResolvedValue(profile({ role: providerRole })) }), + ) + + await expect( + authentication.complete("code", "state", "browser-token"), + ).resolves.toMatchObject({ purpose: "sign-in" }) + + expect(identityRecords.saveMembership).toHaveBeenCalledWith({ + userId: membership.userId, + status: "active", + role: providerRole, + source: "oidc-assignment", + suspendedAt: null, + removedAt: null, + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.provider_role.refresh", + outcome: "succeeded", + actorUserId: "user", + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: membership.id, + context: { reason }, + }) + expect(identityRecords.saveMembership.mock.invocationCallOrder[0]).toBeLessThan( + identityRecords.audit.mock.invocationCallOrder.find( + (order, index) => + identityRecords.audit.mock.calls[index]?.[0].event === + "membership.provider_role.refresh", + )!, + ) + expect(identityRecords.createSession).toHaveBeenCalledOnce() + }, + ) + + it("keeps a suspended member denied even when current provider claims still grant a role", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(existingIdentity()), + membership: vi.fn().mockResolvedValue({ + ...activeMembership(), + status: "suspended", + suspendedAt: now, + }), + }) + const { authentication } = service(identityRecords) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.createSession).not.toHaveBeenCalled() + }) + + it("requires a fresh exact invitation before reactivating a removed member", async () => { + const removed = { + ...activeMembership(), + status: "removed" as const, + removedAt: now, + } + const identity = existingIdentity() + const deniedRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(removed), + }) + + await expect( + service(deniedRecords).authentication.complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) + + const invitedRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(removed), + usableInvitation: vi.fn().mockResolvedValue(invitation()), + }) + + const invitedResult = await service(invitedRecords).authentication.complete( + "code", + "state", + "browser-token", + ) + expect(invitedResult.sessionToken).toEqual(expect.any(String)) + expect(invitedRecords.usableInvitation).toHaveBeenCalledWith( + "oidc", + issuer, + "a0e1d2c3-b4a5-4678-9abc-def012345678", + now, + ) + expect(invitedRecords.saveMembership).toHaveBeenCalledWith({ + userId: "user", + status: "active", + role: "member", + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + expect(invitedRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + }) + + it("keeps an administrator invitation available when the provider grants only member", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation("administrator")), + }) + + await expect( + service(identityRecords, oidc(), { + membershipPolicy: "invitation-required", + }).authentication.complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) + + expect(identityRecords.createUser).not.toHaveBeenCalled() + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + expect(identityRecords.consumeInvitation).not.toHaveBeenCalled() + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "authentication.admission_denied", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: null, + invitationId: "invitation", + context: { reason: "administrator_role_required" }, + }) + }) + + it.each([ + ["member", "member"], + ["member", "administrator"], + ["administrator", "administrator"], + ] as const)( + "activates a %s invitation with a %s provider role without changing invitation authority", + async (invitationRole, providerRole) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation(invitationRole)), + }) + const client = oidc({ + identify: vi.fn().mockResolvedValue(profile({ role: providerRole })), + }) + + await service(identityRecords, client, { + membershipPolicy: "invitation-required", + }).authentication.complete("code", "state", "browser-token") + + expect(identityRecords.saveMembership).toHaveBeenCalledWith({ + userId: "user", + status: "active", + role: invitationRole, + source: "invitation", + suspendedAt: null, + removedAt: null, + }) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + }, + ) + + it("activates a pre-sign-in invitation by directory object ID while retaining the OIDC subject", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation()), + }) + + await service(identityRecords, oidc(), { + membershipPolicy: "invitation-required", + }).authentication.complete("code", "state", "browser-token") + + expect(identityRecords.usableInvitation).toHaveBeenCalledWith( + "oidc", + issuer, + "a0e1d2c3-b4a5-4678-9abc-def012345678", + now, + ) + expect(identityRecords.saveExternalIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "oidc", + issuer, + subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + }), + ) + }) + + it.each([ + ["bootstrap", "membership.bootstrap", "unavailable"], + ["recovery", "membership.recovery", "active_administrator_exists"], + ] as const)( + "audits a %s denial when enrollment becomes unavailable after initiation", + async (purpose, event, reason) => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt({ purpose })), + completeBootstrap: vi.fn().mockResolvedValue(purpose !== "bootstrap"), + activeAdministratorCount: vi.fn().mockResolvedValue(purpose === "recovery" ? 1 : 0), + }) + const { authentication } = service( + identityRecords, + oidc({ identify: vi.fn().mockResolvedValue(profile({ role: "administrator" })) }), + ) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject( + { + status: 403, + code: "admission_denied", + }, + ) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event, + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + context: { reason }, + }) + expect(identityRecords.createSession).not.toHaveBeenCalled() + }, + ) + + it("keeps invitation-required admission distinct from just-in-time assignment", async () => { + const invitationRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + + await expect( + service(invitationRecords, oidc(), { + membershipPolicy: "invitation-required", + }).authentication.complete("code", "state", "browser-token"), + ).rejects.toMatchObject({ status: 403, code: "admission_denied" }) + expect(invitationRecords.createUser).not.toHaveBeenCalled() + + const jitRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const jitResult = await service(jitRecords).authentication.complete( + "code", + "state", + "browser-token", + ) + expect(jitResult.sessionToken).toEqual(expect.any(String)) + expect(jitRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ source: "oidc-assignment" }), + ) + }) + + it("stores only an opaque Hub session digest and never passes provider secrets to persistence or audit", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + }) + const client = oidc() + const { authentication } = service(identityRecords, client) + + const result = await authentication.complete( + "provider-authorization-code", + "state", + "browser-token", + ) + + expect(client.identify).toHaveBeenCalledWith( + "provider-authorization-code", + "pkce-verifier", + "one-use-nonce", + ) + expect(identityRecords.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + tokenDigest: digestSecret(result.sessionToken!), + authenticationProviderRevisionId: providerRevision.id, + signedInAt: now, + freshAuthorizedAt: null, + }), + ) + const persistence = JSON.stringify([ + identityRecords.createUser.mock.calls, + identityRecords.saveExternalIdentity.mock.calls, + identityRecords.saveMembership.mock.calls, + identityRecords.createSession.mock.calls, + identityRecords.audit.mock.calls, + ]) + expect(persistence).not.toMatch( + /provider-authorization-code|client-secret-fixture|browser-token|pkce-verifier|one-use-nonce/, + ) + }) +}) diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts new file mode 100644 index 00000000..4e4ab6ea --- /dev/null +++ b/hub/server/domains/authentication/create-service/types.ts @@ -0,0 +1,67 @@ +import type { IdentityStore, SessionContext } from "#server/domains/identity/create-store/types" +import type { + GitHubUserAuthClient, + GitHubUserAuthConfiguration, + GitHubUserProfile, +} from "#server/integrations/github/create-user-auth-client/types" +import type { OidcClient, OidcConfiguration } from "#server/integrations/oidc/create-client/types" +import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" +import type { GitHubAdmissionBaseline } from "#server/domains/github-authorization/create-service/types" +import type { Prisma } from "../../../generated/prisma/client.ts" + +export type AuthorizationPurpose = "sign-in" | "verify" | "bootstrap" | "recovery" + +export interface AdmissionPolicy { + name: "hub-managed" | "github-synchronized" | "legacy-repository" + admit(profile: GitHubUserProfile): Promise + saveBaseline?( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + ): Promise +} + +export interface AuthenticationSettings { + origin: string + secureCookies: boolean + configured: boolean + provider?: "github" | "oidc" | null + providerLabel?: string | null + admissionPolicy: AdmissionPolicy["name"] + trustedProxyHops: number +} + +export interface AuthenticationContext extends SessionContext { + membership: InstallationMembershipRecord +} + +export interface AuthenticationService { + start( + purpose: AuthorizationPurpose, + sessionToken?: string, + enrollmentSecret?: string, + ): Promise<{ authorizationUrl: string; browserToken: string }> + complete( + code: string, + state: string, + browserToken: string, + ): Promise<{ sessionToken?: string; purpose: AuthorizationPurpose }> + current(sessionToken: string): Promise + signOut(sessionToken: string): Promise + revokeAll(sessionToken: string): Promise +} + +export interface AuthenticationDependencies { + store: IdentityStore + github: GitHubUserAuthClient | null + configuration: GitHubUserAuthConfiguration | null + providerRevisionId: string | null + oidc?: OidcClient | null + oidcConfiguration?: OidcConfiguration | null + oidcProviderRevisionId?: string | null + provider?: "github" | "oidc" + admissionPolicy: AdmissionPolicy + bootstrapSecret?: string | null + recoverySecret?: string | null + now: () => Date +} diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.db.test.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.db.test.ts new file mode 100644 index 00000000..cd325acb --- /dev/null +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.db.test.ts @@ -0,0 +1,61 @@ +import { PrismaPg } from "@prisma/adapter-pg" +import { describe, expect, it, onTestFinished } from "vitest" + +import { ensureOidcProviderRevision } from "#server/domains/authentication/ensure-oidc-provider-revision" +import { createIdentityStore } from "#server/domains/identity/create-store" +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" + +import { PrismaClient } from "../../../generated/prisma/client.ts" + +const configuration: OidcConfiguration = { + issuer: "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "invitation-required", + enabled: true, + providerRevision: 4, +} + +function databaseClient() { + const connectionString = process.env.DATABASE_URL + if (!connectionString) throw new Error("Set DATABASE_URL before running database tests.") + return new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) +} + +describe("ensureOidcProviderRevision() database behavior", () => { + it("reuses a JSONB-backed revision after a process restart and scope reordering", async () => { + const firstClient = databaseClient() + let restartedClient: PrismaClient | null = null + onTestFinished(async () => { + await Promise.allSettled([ + firstClient.$disconnect(), + ...(restartedClient ? [restartedClient.$disconnect()] : []), + ]) + }) + + const created = await ensureOidcProviderRevision( + createIdentityStore(firstClient), + configuration, + ) + await firstClient.$disconnect() + + restartedClient = databaseClient() + const restartedConfiguration = { + ...configuration, + scopes: ["email", "openid", "profile"], + } + + await expect( + ensureOidcProviderRevision(createIdentityStore(restartedClient), restartedConfiguration), + ).resolves.toMatchObject({ + id: created.id, + revision: configuration.providerRevision, + }) + await expect(restartedClient.authenticationProviderRevision.count()).resolves.toBe(1) + }) +}) diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts new file mode 100644 index 00000000..a8ecbfd1 --- /dev/null +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest" + +import { ensureOidcProviderRevision } from "#server/domains/authentication/ensure-oidc-provider-revision" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +const configuration: OidcConfiguration = { + issuer: "https://login.example.test/tenant/v2.0", + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "invitation-required", + enabled: true, + providerRevision: 4, +} + +function storedConfiguration(overrides: Record = {}) { + return { + clientId: configuration.clientId, + callbackUrl: configuration.callbackUrl, + clientCredentialReference: configuration.clientCredentialReference, + credentialFingerprint: digestSecret(configuration.clientSecret), + scopes: [...configuration.scopes].sort(), + memberRole: configuration.memberRole, + administratorRole: configuration.administratorRole, + membershipPolicy: configuration.membershipPolicy, + ...overrides, + } +} + +function revision(overrides: Record = {}) { + return { + id: "revision-4", + authenticationProviderId: "provider", + revision: 4, + issuer: configuration.issuer, + configuration: storedConfiguration(), + authenticationProvider: { + id: "provider", + key: "oidc", + type: "oidc", + enabled: true, + }, + ...overrides, + } +} + +function store(latest: ReturnType | null) { + const createRevision = vi.fn() + const records = { + latestAuthenticationProviderRevision: vi.fn().mockResolvedValue(latest), + createAuthenticationProviderRevision: createRevision, + } as unknown as IdentityRecords + const identityStore: IdentityStore = { + read: records, + lock: (operation) => operation(records, {} as never), + } + return { + records: records as IdentityRecords & { + latestAuthenticationProviderRevision: ReturnType + createAuthenticationProviderRevision: ReturnType + }, + createRevision, + identityStore, + } +} + +describe("ensureOidcProviderRevision()", () => { + it("reuses the same revision when issuer, enabled status, and nonsecret configuration match", async () => { + const existing = revision() + const fixture = store(existing) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).resolves.toBe( + existing, + ) + expect(fixture.createRevision).not.toHaveBeenCalled() + }) + + it("reuses the same revision when stored JSON keys and scopes have a different order", async () => { + const existing = revision({ + configuration: { + membershipPolicy: configuration.membershipPolicy, + administratorRole: configuration.administratorRole, + memberRole: configuration.memberRole, + scopes: ["profile", "email", "openid"], + credentialFingerprint: digestSecret(configuration.clientSecret), + clientCredentialReference: configuration.clientCredentialReference, + callbackUrl: configuration.callbackUrl, + clientId: configuration.clientId, + }, + }) + const fixture = store(existing) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).resolves.toBe( + existing, + ) + expect(fixture.createRevision).not.toHaveBeenCalled() + }) + + it("rejects configuration drift without an increased revision", async () => { + const fixture = store( + revision({ + configuration: storedConfiguration({ scopes: ["openid", "profile"] }), + }), + ) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).rejects.toThrow( + "HUB_OIDC_PROVIDER_REVISION must increase when OIDC configuration changes.", + ) + expect(fixture.createRevision).not.toHaveBeenCalled() + }) + + it("rejects a provider revision rollback", async () => { + const fixture = store(revision({ revision: 5 })) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).rejects.toThrow( + "HUB_OIDC_PROVIDER_REVISION cannot roll back an existing provider.", + ) + expect(fixture.createRevision).not.toHaveBeenCalled() + }) + + it("persists an increased revision with enabled status, reference, and fingerprint but no credential", async () => { + const fixture = store(revision({ revision: 3, id: "revision-3" })) + const created = revision() + fixture.createRevision.mockResolvedValue(created) + + await expect(ensureOidcProviderRevision(fixture.identityStore, configuration)).resolves.toBe( + created, + ) + + expect(fixture.createRevision).toHaveBeenCalledWith({ + key: "oidc", + type: "oidc", + enabled: true, + revision: 4, + issuer: configuration.issuer, + configuration: storedConfiguration(), + }) + const persisted = JSON.stringify(fixture.createRevision.mock.calls) + expect(persisted).toContain(configuration.clientCredentialReference) + expect(persisted).toContain(digestSecret(configuration.clientSecret)) + expect(persisted).not.toContain(configuration.clientSecret) + }) +}) diff --git a/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts new file mode 100644 index 00000000..a048bf08 --- /dev/null +++ b/hub/server/domains/authentication/ensure-oidc-provider-revision/index.ts @@ -0,0 +1,82 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" +import { digestSecret } from "#server/utils/digest-secret" +import { z } from "zod" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +const storedConfigurationSchema = z + .object({ + clientId: z.string(), + callbackUrl: z.string(), + clientCredentialReference: z.string(), + credentialFingerprint: z.string(), + scopes: z.array(z.string()), + memberRole: z.string(), + administratorRole: z.string(), + membershipPolicy: z.enum(["jit-assignment", "invitation-required"]), + }) + .strict() + +function canonicalScopes(scopes: string[]): string[] { + return [...new Set(scopes)].sort() +} + +function storedConfiguration(configuration: OidcConfiguration): Prisma.InputJsonObject { + return { + clientId: configuration.clientId, + callbackUrl: configuration.callbackUrl, + clientCredentialReference: configuration.clientCredentialReference, + credentialFingerprint: digestSecret(configuration.clientSecret), + scopes: canonicalScopes(configuration.scopes), + memberRole: configuration.memberRole, + administratorRole: configuration.administratorRole, + membershipPolicy: configuration.membershipPolicy, + } +} + +function matches(value: unknown, expected: Prisma.InputJsonObject): boolean { + const parsedValue = storedConfigurationSchema.safeParse(value) + const parsedExpected = storedConfigurationSchema.safeParse(expected) + if (!parsedValue.success || !parsedExpected.success) return false + return ( + parsedValue.data.clientId === parsedExpected.data.clientId && + parsedValue.data.callbackUrl === parsedExpected.data.callbackUrl && + parsedValue.data.clientCredentialReference === parsedExpected.data.clientCredentialReference && + parsedValue.data.credentialFingerprint === parsedExpected.data.credentialFingerprint && + parsedValue.data.memberRole === parsedExpected.data.memberRole && + parsedValue.data.administratorRole === parsedExpected.data.administratorRole && + parsedValue.data.membershipPolicy === parsedExpected.data.membershipPolicy && + canonicalScopes(parsedValue.data.scopes).join("\0") === + canonicalScopes(parsedExpected.data.scopes).join("\0") + ) +} + +export function ensureOidcProviderRevision(store: IdentityStore, configuration: OidcConfiguration) { + return store.lock(async (records) => { + const latest = await records.latestAuthenticationProviderRevision("oidc") + const stored = storedConfiguration(configuration) + if (latest && latest.revision > configuration.providerRevision) { + throw new Error("HUB_OIDC_PROVIDER_REVISION cannot roll back an existing provider.") + } + if (latest?.revision === configuration.providerRevision) { + if ( + latest.authenticationProvider.type !== "oidc" || + latest.issuer !== configuration.issuer || + latest.authenticationProvider.enabled !== configuration.enabled || + !matches(latest.configuration, stored) + ) { + throw new Error("HUB_OIDC_PROVIDER_REVISION must increase when OIDC configuration changes.") + } + return latest + } + return records.createAuthenticationProviderRevision({ + key: "oidc", + type: "oidc", + enabled: configuration.enabled, + revision: configuration.providerRevision, + issuer: configuration.issuer, + configuration: stored, + }) + }) +} diff --git a/hub/server/domains/authentication/ensure-provider-admission-policy/index.test.ts b/hub/server/domains/authentication/ensure-provider-admission-policy/index.test.ts new file mode 100644 index 00000000..29bb437a --- /dev/null +++ b/hub/server/domains/authentication/ensure-provider-admission-policy/index.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest" + +import { ensureProviderAdmissionPolicy } from "#server/domains/authentication/ensure-provider-admission-policy" + +describe("ensureProviderAdmissionPolicy()", () => { + it.each(["github-synchronized", "legacy-repository"])( + "rejects OIDC with the non-Hub-managed %s admission policy", + (policy) => { + expect(() => ensureProviderAdmissionPolicy("oidc", { HUB_ADMISSION_POLICY: policy })).toThrow( + "OIDC authentication requires HUB_ADMISSION_POLICY=hub-managed.", + ) + }, + ) + + it("allows Hub-managed OIDC and preserves GitHub admission policy choices", () => { + expect(() => ensureProviderAdmissionPolicy("oidc", {})).not.toThrow() + expect(() => + ensureProviderAdmissionPolicy("github", { + HUB_ADMISSION_POLICY: "github-synchronized", + }), + ).not.toThrow() + }) +}) diff --git a/hub/server/domains/authentication/ensure-provider-admission-policy/index.ts b/hub/server/domains/authentication/ensure-provider-admission-policy/index.ts new file mode 100644 index 00000000..31a7da19 --- /dev/null +++ b/hub/server/domains/authentication/ensure-provider-admission-policy/index.ts @@ -0,0 +1,9 @@ +export function ensureProviderAdmissionPolicy( + provider: "github" | "oidc", + environment: NodeJS.ProcessEnv, +): void { + const policy = environment.HUB_ADMISSION_POLICY ?? "hub-managed" + if (provider === "oidc" && policy !== "hub-managed") { + throw new Error("OIDC authentication requires HUB_ADMISSION_POLICY=hub-managed.") + } +} diff --git a/hub/server/domains/authentication/ensure-provider-revision/index.ts b/hub/server/domains/authentication/ensure-provider-revision/index.ts new file mode 100644 index 00000000..af7cf76a --- /dev/null +++ b/hub/server/domains/authentication/ensure-provider-revision/index.ts @@ -0,0 +1,50 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { digestSecret } from "#server/utils/digest-secret" + +function storedConfiguration(configuration: GitHubUserAuthConfiguration): Record { + return { + clientId: configuration.clientId, + callbackUrl: configuration.callbackUrl, + fingerprint: digestSecret( + `${configuration.clientId}\0${configuration.clientSecret}\0${configuration.callbackUrl}`, + ), + } +} + +function matches(value: unknown, expected: Record): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + return ( + "clientId" in value && + value.clientId === expected.clientId && + "callbackUrl" in value && + value.callbackUrl === expected.callbackUrl && + "fingerprint" in value && + value.fingerprint === expected.fingerprint + ) +} + +export function ensureGitHubProviderRevision( + store: IdentityStore, + configuration: GitHubUserAuthConfiguration, +) { + return store.lock(async (records) => { + const current = await records.currentAuthenticationProviderRevision("github") + const stored = storedConfiguration(configuration) + if ( + current?.authenticationProvider.type === "github" && + current.issuer === configuration.issuer && + matches(current.configuration, stored) + ) { + return current + } + return records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: (current?.revision ?? 0) + 1, + issuer: configuration.issuer, + configuration: stored, + }) + }) +} diff --git a/hub/server/domains/authentication/fresh-authorization/constants.ts b/hub/server/domains/authentication/fresh-authorization/constants.ts new file mode 100644 index 00000000..471e4c61 --- /dev/null +++ b/hub/server/domains/authentication/fresh-authorization/constants.ts @@ -0,0 +1 @@ +export const administratorFreshAuthorizationLifetimeMilliseconds = 5 * 60_000 diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts new file mode 100644 index 00000000..695e3c64 --- /dev/null +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -0,0 +1,26 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" + +export async function getCurrentSession( + dependencies: AuthenticationDependencies, + sessionToken: string, + records: IdentityRecords = dependencies.store.read, +) { + const session = sessionToken ? await records.session(digestSecret(sessionToken)) : null + if (!session) { + throw new AuthenticationError(401, "sign_in_required", "Sign in to continue.") + } + const membership = await records.membership(session.userId) + if (!membership || membership.status !== "active") { + throw new AuthenticationError(401, "membership_inactive", "Sign in to continue.") + } + if ( + membership.source === "synchronized" && + !(await records.hasFreshGitHubAdmission(session.userId, dependencies.now())) + ) { + throw new AuthenticationError(401, "membership_inactive", "Sign in to continue.") + } + return { ...session, membership } +} diff --git a/hub/server/domains/authentication/read-authentication-provider/index.test.ts b/hub/server/domains/authentication/read-authentication-provider/index.test.ts new file mode 100644 index 00000000..80e3e2af --- /dev/null +++ b/hub/server/domains/authentication/read-authentication-provider/index.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest" + +import { readAuthenticationProvider } from "#server/domains/authentication/read-authentication-provider" + +describe("readAuthenticationProvider()", () => { + it.each(["github", "oidc"] as const)( + "returns the explicitly configured %s provider", + (provider) => { + expect(readAuthenticationProvider({ HUB_AUTH_PROVIDER: provider })).toBe(provider) + }, + ) + + it.each([undefined, "", "GitHub", "oidc ", "other"])( + "rejects a missing or unsupported provider value %s", + (provider) => { + expect(() => readAuthenticationProvider({ HUB_AUTH_PROVIDER: provider })).toThrow( + "Set HUB_AUTH_PROVIDER to github or oidc.", + ) + }, + ) +}) diff --git a/hub/server/domains/authentication/read-authentication-provider/index.ts b/hub/server/domains/authentication/read-authentication-provider/index.ts new file mode 100644 index 00000000..a8857c67 --- /dev/null +++ b/hub/server/domains/authentication/read-authentication-provider/index.ts @@ -0,0 +1,7 @@ +export function readAuthenticationProvider(environment: NodeJS.ProcessEnv): "github" | "oidc" { + const provider = environment.HUB_AUTH_PROVIDER + if (provider !== "github" && provider !== "oidc") { + throw new Error("Set HUB_AUTH_PROVIDER to github or oidc.") + } + return provider +} diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.test.ts b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts new file mode 100644 index 00000000..28e62045 --- /dev/null +++ b/hub/server/domains/authentication/read-oidc-configuration/index.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest" + +import { readOidcConfiguration } from "#server/domains/authentication/read-oidc-configuration" + +function environment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + HUB_OIDC_ENABLED: "true", + HUB_OIDC_ISSUER: "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + HUB_OIDC_CLIENT_ID: "hub-client", + HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE: "HUB_OIDC_CLIENT_SECRET", + HUB_OIDC_CLIENT_SECRET: "client-secret-fixture", + HUB_OIDC_CALLBACK_URL: "https://hub.example.test/api/auth/oidc/callback", + HUB_OIDC_SCOPES: "openid profile email", + HUB_OIDC_MEMBER_ROLE: "Hub.User", + HUB_OIDC_ADMINISTRATOR_ROLE: "Hub.Administrator", + HUB_OIDC_MEMBERSHIP_POLICY: "invitation-required", + HUB_OIDC_PROVIDER_REVISION: "4", + ...overrides, + } +} + +const origin = new URL("https://hub.example.test") + +describe("readOidcConfiguration()", () => { + it("returns a complete configuration with the credential resolved only through its reference", () => { + expect(readOidcConfiguration(environment(), origin)).toEqual({ + issuer: "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "invitation-required", + enabled: true, + providerRevision: 4, + }) + }) + + it("normalizes tenant UUID case and a trailing issuer slash", () => { + const configuration = readOidcConfiguration( + environment({ + HUB_OIDC_ISSUER: + "https://login.microsoftonline.com/A0B1C2D3-E4F5-4A67-8B9C-0D1E2F3A4B5C/v2.0/", + }), + origin, + ) + + expect(configuration.issuer).toBe( + "https://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ) + }) + + it("fails closed when the referenced credential is missing", () => { + const input = environment() + delete input.HUB_OIDC_CLIENT_SECRET + + expect(() => readOidcConfiguration(input, origin)).toThrow( + "Set HUB_OIDC_CLIENT_SECRET when HUB_AUTH_PROVIDER=oidc.", + ) + }) + + it("rejects a callback that does not exactly match the Hub OIDC callback", () => { + expect(() => + readOidcConfiguration( + environment({ + HUB_OIDC_CALLBACK_URL: "https://hub.example.test/api/auth/oidc/other-callback", + }), + origin, + ), + ).toThrow("HUB_OIDC_CALLBACK_URL must match the Hub OIDC callback URL.") + }) + + it("rejects an insecure issuer before accepting provider configuration", () => { + expect(() => + readOidcConfiguration( + environment({ + HUB_OIDC_ISSUER: + "http://login.microsoftonline.com/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + }), + origin, + ), + ).toThrow("HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.") + }) + + it.each([ + [ + "a generic provider host", + "https://login.example.test/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ], + ["a private address", "https://10.0.0.1/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0"], + [ + "a DNS rebinding-capable host", + "https://127.0.0.1.nip.io/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ], + [ + "a Microsoft login alias", + "https://login.windows.net/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ], + [ + "an explicit port", + "https://login.microsoftonline.com:443/a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5c/v2.0", + ], + ])("rejects %s", (_description, issuer) => { + expect(() => readOidcConfiguration(environment({ HUB_OIDC_ISSUER: issuer }), origin)).toThrow( + "HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.", + ) + }) + + it.each([ + ["a tenant alias", "organizations"], + ["a non-UUID tenant", "tenant"], + ["a truncated UUID", "a0b1c2d3-e4f5-4a67-8b9c-0d1e2f3a4b5"], + ])("rejects %s in the tenant segment", (_description, tenant) => { + expect(() => + readOidcConfiguration( + environment({ + HUB_OIDC_ISSUER: `https://login.microsoftonline.com/${tenant}/v2.0`, + }), + origin, + ), + ).toThrow("HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.") + }) + + it.each([ + ["without openid", "profile email"], + ["without profile", "openid email"], + ["with offline_access", "openid profile email offline_access"], + ])("rejects scopes %s", (_description, scopes) => { + expect(() => readOidcConfiguration(environment({ HUB_OIDC_SCOPES: scopes }), origin)).toThrow( + "HUB_OIDC_SCOPES must include openid and profile and must not request offline_access.", + ) + }) + + it("rejects an unknown membership policy instead of falling back to JIT admission", () => { + expect(() => + readOidcConfiguration( + environment({ HUB_OIDC_MEMBERSHIP_POLICY: "provider-default" }), + origin, + ), + ).toThrow("HUB_OIDC_MEMBERSHIP_POLICY must be jit-assignment or invitation-required.") + }) + + it.each(["0", "-1", "1.5", "not-a-number"])( + "rejects invalid provider revision %s", + (providerRevision) => { + expect(() => + readOidcConfiguration( + environment({ HUB_OIDC_PROVIDER_REVISION: providerRevision }), + origin, + ), + ).toThrow("HUB_OIDC_PROVIDER_REVISION must be a positive integer.") + }, + ) + + it("accepts the largest incrementable database provider revision", () => { + expect( + readOidcConfiguration(environment({ HUB_OIDC_PROVIDER_REVISION: "2147483646" }), origin) + .providerRevision, + ).toBe(2_147_483_646) + }) + + it("rejects a provider revision that cannot be incremented in the database", () => { + expect(() => + readOidcConfiguration(environment({ HUB_OIDC_PROVIDER_REVISION: "2147483647" }), origin), + ).toThrow("HUB_OIDC_PROVIDER_REVISION must fit the incrementable database integer range.") + }) +}) diff --git a/hub/server/domains/authentication/read-oidc-configuration/index.ts b/hub/server/domains/authentication/read-oidc-configuration/index.ts new file mode 100644 index 00000000..d8651278 --- /dev/null +++ b/hub/server/domains/authentication/read-oidc-configuration/index.ts @@ -0,0 +1,90 @@ +import type { OidcConfiguration } from "#server/integrations/oidc/create-client/types" +import { normalizeEntraIssuer } from "#server/utils/normalize-entra-issuer" + +function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, key: string): string { + const value = environment[key] + if (!value) throw new Error(`Set ${key} when HUB_AUTH_PROVIDER=oidc.`) + return value +} + +export function readOidcConfiguration( + environment: NodeJS.ProcessEnv, + origin: URL, +): OidcConfiguration { + const enabledValue = requiredEnvironmentValue(environment, "HUB_OIDC_ENABLED") + if (!["true", "false"].includes(enabledValue)) { + throw new Error("HUB_OIDC_ENABLED must be true or false.") + } + const issuerValue = requiredEnvironmentValue(environment, "HUB_OIDC_ISSUER") + const issuer = normalizeEntraIssuer(issuerValue) + if (!issuer) { + throw new Error("HUB_OIDC_ISSUER must be https://login.microsoftonline.com//v2.0.") + } + const callbackUrl = requiredEnvironmentValue(environment, "HUB_OIDC_CALLBACK_URL") + if (callbackUrl !== `${origin.origin}/api/auth/oidc/callback`) { + throw new Error("HUB_OIDC_CALLBACK_URL must match the Hub OIDC callback URL.") + } + const clientCredentialReference = requiredEnvironmentValue( + environment, + "HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE", + ) + if (!/^[A-Z][A-Z0-9_]{2,127}$/.test(clientCredentialReference)) { + throw new Error("HUB_OIDC_CLIENT_CREDENTIAL_REFERENCE must name an environment variable.") + } + const clientSecret = requiredEnvironmentValue(environment, clientCredentialReference) + if (clientSecret.length < 16 || clientSecret.length > 4_096) { + throw new Error("The referenced OIDC client credential must contain 16 to 4096 characters.") + } + const scopes = requiredEnvironmentValue(environment, "HUB_OIDC_SCOPES") + .split(/\s+/) + .filter(Boolean) + if ( + !scopes.includes("openid") || + !scopes.includes("profile") || + scopes.includes("offline_access") || + scopes.some((scope) => !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope)) + ) { + throw new Error( + "HUB_OIDC_SCOPES must include openid and profile and must not request offline_access.", + ) + } + const memberRole = requiredEnvironmentValue(environment, "HUB_OIDC_MEMBER_ROLE") + const administratorRole = requiredEnvironmentValue(environment, "HUB_OIDC_ADMINISTRATOR_ROLE") + if ( + memberRole === administratorRole || + memberRole.length > 255 || + administratorRole.length > 255 + ) { + throw new Error("OIDC member and administrator role values must be distinct and bounded.") + } + const membershipPolicy = requiredEnvironmentValue(environment, "HUB_OIDC_MEMBERSHIP_POLICY") + if (!["jit-assignment", "invitation-required"].includes(membershipPolicy)) { + throw new Error("HUB_OIDC_MEMBERSHIP_POLICY must be jit-assignment or invitation-required.") + } + const providerRevision = Number( + requiredEnvironmentValue(environment, "HUB_OIDC_PROVIDER_REVISION"), + ) + if (!Number.isInteger(providerRevision) || providerRevision < 1) { + throw new Error("HUB_OIDC_PROVIDER_REVISION must be a positive integer.") + } + if (providerRevision > 2_147_483_646) { + throw new Error("HUB_OIDC_PROVIDER_REVISION must fit the incrementable database integer range.") + } + const clientId = requiredEnvironmentValue(environment, "HUB_OIDC_CLIENT_ID") + if (clientId.length > 255) { + throw new Error("HUB_OIDC_CLIENT_ID must contain at most 255 characters.") + } + return { + issuer, + clientId, + clientSecret, + clientCredentialReference, + callbackUrl, + scopes, + memberRole, + administratorRole, + membershipPolicy: membershipPolicy as OidcConfiguration["membershipPolicy"], + enabled: enabledValue === "true", + providerRevision, + } +} diff --git a/hub/server/domains/authentication/revoke-sessions/index.ts b/hub/server/domains/authentication/revoke-sessions/index.ts new file mode 100644 index 00000000..fd6e8b2e --- /dev/null +++ b/hub/server/domains/authentication/revoke-sessions/index.ts @@ -0,0 +1,40 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import { administratorFreshAuthorizationLifetimeMilliseconds } from "#server/domains/authentication/fresh-authorization/constants" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" + +export async function revokeSessions( + dependencies: AuthenticationDependencies, + sessionToken: string, +): Promise { + const { now, store } = dependencies + await store.lock(async (records) => { + const current = await getCurrentSession(dependencies, sessionToken, records) + if (current.membership?.status !== "active" || current.membership.role !== "administrator") { + throw new AuthenticationError(403, "administrator_required", "Administrator access required.") + } + const freshAuthorizationAge = current.freshAuthorizedAt + ? now().getTime() - current.freshAuthorizedAt.getTime() + : null + if ( + freshAuthorizationAge === null || + freshAuthorizationAge < 0 || + freshAuthorizationAge >= administratorFreshAuthorizationLifetimeMilliseconds + ) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Fresh authorization required.", + ) + } + await records.advanceSessionGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + outcome: "succeeded", + actorUserId: current.userId, + sessionId: current.id, + authenticationProviderRevisionId: current.authenticationProviderRevisionId, + }) + }) +} diff --git a/hub/server/domains/authentication/sign-out/index.ts b/hub/server/domains/authentication/sign-out/index.ts new file mode 100644 index 00000000..0f8160fa --- /dev/null +++ b/hub/server/domains/authentication/sign-out/index.ts @@ -0,0 +1,19 @@ +import type { AuthenticationDependencies } from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" + +export async function signOut( + dependencies: AuthenticationDependencies, + sessionToken: string, +): Promise { + await dependencies.store.lock(async (records) => { + const current = await getCurrentSession(dependencies, sessionToken, records) + await records.deleteSession(current.id) + await records.audit({ + event: "session.sign_out", + outcome: "succeeded", + actorUserId: current.userId, + sessionId: current.id, + authenticationProviderRevisionId: current.authenticationProviderRevisionId, + }) + }) +} diff --git a/hub/server/domains/authentication/start-github-authorization/index.ts b/hub/server/domains/authentication/start-github-authorization/index.ts new file mode 100644 index 00000000..92886b82 --- /dev/null +++ b/hub/server/domains/authentication/start-github-authorization/index.ts @@ -0,0 +1,106 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function startGitHubAuthorization( + dependencies: AuthenticationDependencies, + purpose: AuthorizationPurpose, + sessionToken?: string, + enrollmentSecret?: string, +) { + const { bootstrapSecret, configuration, github, now, providerRevisionId, recoverySecret, store } = + dependencies + if (!configuration || !github || !providerRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "GitHub authentication is not configured.", + ) + } + const state = randomBytes(32).toString("base64url") + const browserToken = randomBytes(32).toString("base64url") + const pkceVerifier = randomBytes(32).toString("base64url") + const denial = await store.lock(async (records) => { + const provider = await records.currentAuthenticationProviderRevision("github") + if (provider?.id !== providerRevisionId) { + throw new AuthenticationError( + 409, + "provider_changed", + "GitHub authentication changed. Start again.", + ) + } + const session = + purpose === "verify" + ? await getCurrentSession(dependencies, sessionToken ?? "", records) + : null + const stateRecord = await records.identityStoreState() + if (purpose === "bootstrap") { + const reason = (await records.bootstrapCompletedAt()) + ? "already_completed" + : !bootstrapSecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) + ? "secret_mismatch" + : null + if (reason) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") + } + } + if (purpose === "recovery") { + const reason = !recoverySecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) + ? "secret_mismatch" + : (await records.activeAdministratorCount()) > 0 + ? "active_administrator_exists" + : null + if (reason) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") + } + } + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: providerRevisionId, + originatingSessionId: session?.id ?? null, + stateDigest: digestSecret(state), + browserDigest: digestSecret(browserToken), + pkceVerifier, + nonce: null, + purpose, + sessionGeneration: stateRecord.sessionGeneration, + expiresAt: new Date(now().getTime() + 10 * 60_000), + }) + return null + }) + if (denial) throw denial + return { + authorizationUrl: github.authorizationUrl(state, pkceVerifier), + browserToken, + } +} diff --git a/hub/server/domains/authentication/start-oidc-authorization/index.ts b/hub/server/domains/authentication/start-oidc-authorization/index.ts new file mode 100644 index 00000000..912617bd --- /dev/null +++ b/hub/server/domains/authentication/start-oidc-authorization/index.ts @@ -0,0 +1,137 @@ +import { randomBytes } from "node:crypto" + +import type { + AuthenticationDependencies, + AuthorizationPurpose, +} from "#server/domains/authentication/create-service/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" +import { secretsMatch } from "#server/utils/secrets-match" + +export async function startOidcAuthorization( + dependencies: AuthenticationDependencies, + purpose: AuthorizationPurpose, + sessionToken?: string, + enrollmentSecret?: string, +) { + const { + bootstrapSecret, + now, + oidc, + oidcConfiguration, + oidcProviderRevisionId, + recoverySecret, + store, + } = dependencies + if (!oidcConfiguration?.enabled || !oidc || !oidcProviderRevisionId) { + throw new AuthenticationError( + 503, + "authentication_unconfigured", + "OIDC authentication is not configured.", + ) + } + const state = randomBytes(32).toString("base64url") + const browserToken = randomBytes(32).toString("base64url") + const pkceVerifier = randomBytes(32).toString("base64url") + const nonce = randomBytes(32).toString("base64url") + const denial = await store.lock(async (records) => { + const provider = await records.currentAuthenticationProviderRevision("oidc") + if (provider?.id !== oidcProviderRevisionId) { + throw new AuthenticationError( + 409, + "provider_changed", + "OIDC authentication changed. Start again.", + ) + } + const session = + purpose === "verify" + ? await getCurrentSession(dependencies, sessionToken ?? "", records) + : null + const stateRecord = await records.identityStoreState() + if (purpose === "bootstrap") { + const reason = (await records.bootstrapCompletedAt()) + ? "already_completed" + : !bootstrapSecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) + ? "secret_mismatch" + : null + if (reason) { + await records.audit({ + event: "membership.bootstrap", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") + } + } + if (purpose === "recovery") { + const reason = !recoverySecret + ? "secret_unconfigured" + : !enrollmentSecret + ? "secret_missing" + : !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) + ? "secret_mismatch" + : (await records.activeAdministratorCount()) > 0 + ? "active_administrator_exists" + : null + if (reason) { + await records.audit({ + event: "membership.recovery", + outcome: "denied", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + context: { reason }, + }) + return new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") + } + } + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: oidcProviderRevisionId, + originatingSessionId: session?.id ?? null, + stateDigest: digestSecret(state), + browserDigest: digestSecret(browserToken), + pkceVerifier, + nonce, + purpose, + sessionGeneration: stateRecord.sessionGeneration, + expiresAt: new Date(now().getTime() + 10 * 60_000), + }) + return null + }) + if (denial) throw denial + let authorizationUrl: string + try { + authorizationUrl = + purpose === "verify" + ? await oidc.authorizationUrl(state, pkceVerifier, nonce, { fresh: true }) + : await oidc.authorizationUrl(state, pkceVerifier, nonce) + } catch { + await store.lock(async (records) => { + await records.consumeAuthorizationAttempt(digestSecret(state)) + await records.audit({ + event: "authentication.provider_failed", + outcome: "failed", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: oidcProviderRevisionId, + }) + }) + throw new AuthenticationError( + 503, + "authentication_provider_failed", + "The authentication provider failed.", + ) + } + return { + authorizationUrl, + browserToken, + } +} diff --git a/hub/server/domains/github-authorization/create-service/index.db.test.ts b/hub/server/domains/github-authorization/create-service/index.db.test.ts new file mode 100644 index 00000000..bffa8315 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/index.db.test.ts @@ -0,0 +1,3945 @@ +import { AsyncLocalStorage } from "node:async_hooks" +import { createHmac, generateKeyPairSync } from "node:crypto" +import { readFileSync } from "node:fs" +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createAdministrationService } from "#server/domains/administration/create-service" +import { createAuthenticationService } from "#server/domains/authentication/create-service" +import { createGitHubAuthorizationService } from "#server/domains/github-authorization/create-service" +import type { + GitHubAdmissionBaseline, + GitHubAuthorizationService, +} from "#server/domains/github-authorization/create-service/types" +import { createIdentityStore } from "#server/domains/identity/create-store" +import { logger } from "#server/integrations/pino" +import { AuthenticationError } from "#server/utils/authentication-error" +import { digestSecret } from "#server/utils/digest-secret" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +type IdentityStore = ReturnType + +function environment() { + const privateKey = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey + return { + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", + HUB_GITHUB_WEBHOOK_SECRET: "synthetic-webhook-secret", + GITHUB_APP_ID: "12345", + GITHUB_INSTALLATION_ID: "7", + GITHUB_CLIENT_ID: "test-client", + GITHUB_CLIENT_SECRET: "synthetic-client-secret", + GITHUB_PRIVATE_KEY: privateKey, + } +} + +async function database(environmentOverrides: NodeJS.ProcessEnv = {}) { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + const store = createIdentityStore(prisma) + return { + prisma, + store, + service: createGitHubAuthorizationService({ + database: prisma, + environment: { ...environment(), ...environmentOverrides }, + store, + }), + } +} + +async function createUser( + store: IdentityStore, + { + subject, + membershipSource = "manual", + role = "member", + status = "active", + token, + }: { + subject: string + membershipSource?: "legacy-synchronized" | "manual" | "synchronized" + role?: "administrator" | "member" + status?: "active" | "removed" | "suspended" + token?: string + }, +) { + return store.lock(async (records) => { + const user = await records.createUser({ displayName: `User ${subject}`, avatarUrl: null }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `user-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status, + role, + source: membershipSource, + suspendedAt: status === "suspended" ? new Date() : null, + removedAt: status === "removed" ? new Date() : null, + }) + const session = token + ? await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }) + : null + return { identity, membership, session, user } + }) +} + +function webhook(body: object, event: string, deliveryId: string) { + const encoded = Buffer.from(JSON.stringify(body)) + return { + body: encoded, + deliveryId, + event, + signature: `sha256=${createHmac("sha256", "synthetic-webhook-secret") + .update(encoded) + .digest("hex")}`, + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function requestUrl(input: string | URL | Request): string { + return typeof input === "string" ? input : input instanceof URL ? input.href : input.url +} + +async function saveAdmissionFixture( + prisma: PrismaClient, + service: GitHubAuthorizationService, + userId: string, + subject: string, + baseline: Omit, +) { + const attempt = await prisma.gitHubAdmissionAttempt.upsert({ + where: { + repositorySourceId_subject: { + repositorySourceId: baseline.repositorySourceId, + subject, + }, + }, + create: { repositorySourceId: baseline.repositorySourceId, subject, generation: 1 }, + update: { generation: { increment: 1 } }, + }) + await service.saveAdmissionBaseline(prisma, userId, { + ...baseline, + subject, + admissionGeneration: attempt.generation, + persist: true, + }) +} + +describe("createGitHubAuthorizationService() database behavior", () => { + it("uses only a fresh baseline for the linked repository and keeps repositories isolated", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const [source, otherSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "other", + name: "repository", + defaultBranch: "main", + }, + }), + ]) + const request = vi.fn() + vi.stubGlobal("fetch", request) + + await expect( + service.evaluateAdmission({ + subject: "42", + username: "user-42", + displayName: "User 42", + avatarUrl: null, + }), + ).resolves.toBeNull() + expect(request).not.toHaveBeenCalled() + await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { admissionEnabled: true }, + }) + + const workspace = await prisma.workspace.create({ + data: { name: "Workspace", createdByUserId: actor.user.id }, + }) + const [prototype, otherPrototype] = await Promise.all([ + prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }, + }), + prisma.prototype.create({ + data: { + name: "Other", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: otherSource.id, + }, + }), + ]) + const confirmedAt = new Date("2026-10-27T20:00:00.000Z") + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt, + }) + await prisma.gitHubAuthorizationPolicy.create({ + data: { id: "00000000-0000-0000-0000-000000000001", lastKnownGoodSeconds: 60 }, + }) + + await expect( + service.prototypeBaseline(prisma, actor.user.id, prototype.id, confirmedAt), + ).resolves.toMatchObject({ + role: "editor", + repositorySourceId: source.id, + githubRepositoryId: "100", + rawPermission: "write", + }) + await expect( + service.prototypeBaseline(prisma, actor.user.id, otherPrototype.id, confirmedAt), + ).resolves.toBeNull() + await expect( + service.prototypeBaseline( + prisma, + actor.user.id, + prototype.id, + new Date(confirmedAt.getTime() + 61_000), + ), + ).resolves.toBeNull() + }) + + it("removes synchronized state for a repository webhook while preserving manual rules", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const [source, unrelatedSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "unrelated", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt: new Date("2026-10-28T18:00:00.000Z"), + }, + }), + ]) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const input = webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-1", + ) + + await expect(service.processWebhook(input)).resolves.toBe("accepted") + await expect(service.processWebhook(input)).resolves.toBe("duplicate") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "viewer" }) + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: unrelatedSource.id } }), + ).toMatchObject({ + synchronizationGeneration: unrelatedSource.synchronizationGeneration, + synchronizationStatus: "current", + dirtyAt: null, + }) + expect(await prisma.gitHubWebhookDelivery.count()).toBe(1) + }) + + it("reconciles by immutable repository ID with an installation token and revokes lost synchronized access", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "session-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "master", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { userId: actor.user.id, effect: "allow", role: "commenter" }, + }, + }, + }) + const responses = [ + { token: "installation-token" }, + { + id: 100, + full_name: "new-owner/new-name", + name: "new-name", + owner: { login: "new-owner" }, + default_branch: "trunk", + visibility: "private", + }, + { user: { id: 42 }, permission: "none" }, + ] + const request = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( + () => Promise.resolve(new Response(JSON.stringify(responses.shift()), { status: 200 })), + ) + vi.stubGlobal("fetch", request) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect(request.mock.calls.map(([url]) => url)).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/new-owner/new-name/collaborators/user-42/permission", + ]) + expect(new Headers(request.mock.calls[1]?.[1]?.headers).get("authorization")).toBe( + "Bearer installation-token", + ) + const reconciled = await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + expect(reconciled).toMatchObject({ + owner: "new-owner", + name: "new-name", + defaultBranch: "trunk", + synchronizationStatus: "current", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ status: "removed", source: "synchronized" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "commenter" }) + + request.mockReset() + request.mockResolvedValue(new Response(null, { status: 503 })) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + lastConfirmedAt: reconciled?.lastConfirmedAt, + }) + }) + + it("does not resurrect a repository removed while reconciliation is waiting on GitHub", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { userId: actor.user.id, effect: "allow", role: "commenter" }, + }, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const repositoryResponse = deferred() + const repositoryRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequested.resolve() + return repositoryResponse.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { + status: 200, + }), + ) + }), + ) + + const reconciliation = service.reconcileDue() + await repositoryRequested.promise + await service.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-during-reconcile", + ), + ) + repositoryResponse.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "resurrected-owner/resurrected-name", + name: "resurrected-name", + owner: { login: "resurrected-owner" }, + default_branch: "trunk", + visibility: "private", + }), + { status: 200 }, + ), + ) + await reconciliation + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + synchronizationGeneration: 2, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.workspaceAccessGrant.findUnique({ + where: { workspaceId_userId: { workspaceId: workspace.id, userId: actor.user.id } }, + }), + ).toMatchObject({ effect: "allow", role: "commenter" }) + }) + + it("reports a source generation change instead of treating admission as denied", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const sourcesObserved = deferred() + const continueAdmission = deferred() + const findMany = prisma.gitHubRepositorySource.findMany.bind(prisma.gitHubRepositorySource) + vi.spyOn(prisma.gitHubRepositorySource, "findMany").mockImplementation(((args) => + findMany(args).then(async (sources) => { + sourcesObserved.resolve() + await continueAdmission.promise + return sources + })) as typeof prisma.gitHubRepositorySource.findMany) + const request = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + return Promise.resolve( + new Response( + JSON.stringify({ + id: 999, + full_name: "original-owner/original-name", + name: "original-name", + owner: { login: "replacement-owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + }) + vi.stubGlobal("fetch", request) + + const admission = service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + await sourcesObserved.promise + await service.processWebhook( + webhook( + { + action: "added", + installation: { id: 7 }, + repository: { + id: 100, + name: "renamed", + owner: { login: "new-owner" }, + default_branch: "main", + visibility: "private", + }, + }, + "member", + "source-generation-changed-before-claim", + ), + ) + continueAdmission.resolve() + + await expect(admission).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + ]) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "dirty", + }) + }) + + it("claims reconciliation attempts without waiting for the identity-store lock", async () => { + const { prisma, service, store } = await database() + await createUser(store, { subject: "42" }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const lockHeld = deferred() + const releaseLock = deferred() + let permissionRequested = false + const lock = store.lock(async () => { + lockHeld.resolve() + await releaseLock.promise + }) + await lockHeld.promise + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequested = true + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), + ) + }), + ) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => expect(permissionRequested).toBe(true)) + await expect(prisma.gitHubRepositorySource.count()).resolves.toBe(1) + + releaseLock.resolve() + await Promise.all([lock, reconciliation]) + }) + + it("does not start another scheduled reconciliation while one is running", async () => { + const { prisma, service, store } = await database() + await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "original-owner", + name: "original-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const repositoryResponse = deferred() + const repositoryRequested = deferred() + let repositoryRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequests += 1 + repositoryRequested.resolve() + return repositoryResponse.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), + ) + }), + ) + + const firstReconciliation = service.reconcileDue() + await repositoryRequested.promise + + await expect(service.reconcileDue()).resolves.toBeUndefined() + expect(repositoryRequests).toBe(1) + + repositoryResponse.resolve( + new Response( + JSON.stringify({ + id: Number(source.githubRepositoryId), + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + await firstReconciliation + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "current", + synchronizationGeneration: 1, + }) + }) + + it("applies a long manual reconciliation after freshness expires and attributes its audit", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "target-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const permissionResponse = deferred() + const permissionRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + permissionRequested.resolve() + return permissionResponse.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { status: 200 }), + ) + }), + ) + + const reconciliation = service.reconcileRepository("administrator-token", source.id) + await permissionRequested.promise + await prisma.session.update({ + where: { id: administrator.session!.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 6 * 60_000) }, + }) + permissionResponse.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + + await expect(reconciliation).resolves.toMatchObject({ + id: source.id, + synchronizationStatus: "current", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: target.session!.id } })).toBeNull() + expect( + await prisma.securityAudit.findFirst({ + where: { event: "github.repository.reconcile", githubRepositorySourceId: source.id }, + }), + ).toMatchObject({ + actorUserId: administrator.user.id, + sessionId: administrator.session!.id, + authenticationProviderRevisionId: administrator.session!.authenticationProviderRevisionId, + }) + }) + + it("persists a live denial so a later outage cannot reuse the old positive cache", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const [sourceA, sourceB] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + id: "00000000-0000-0000-0000-000000000001", + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository-a", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + id: "00000000-0000-0000-0000-000000000002", + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository-b", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: sourceA.id, + sourceGeneration: sourceA.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository-a", + name: "repository-a", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("/repos/owner/repository-a/")) { + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: 42 }, + permission: "none", + role_name: "none", + }), + { status: 200 }, + ), + ) + } + if (url.endsWith("/repositories/200")) { + return Promise.resolve(new Response(null, { status: 503 })) + } + throw new Error(`Unexpected request: ${url}`) + }), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: sourceA.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + mappedPrototypeRole: null, + status: "removed", + }) + expect(sourceB.id).not.toBe(sourceA.id) + + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 503 }), + ), + ), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + }) + + it("uses a fresh cached grant after metadata succeeds without persisting the fallback", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const confirmedAt = new Date() + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), + ) + + const admission = await service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + expect(admission).toEqual({ + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 0, + persist: false, + rawPermission: "read", + admissionEligible: true, + prototypeRole: "viewer", + confirmedAt, + }) + await service.saveAdmissionBaseline(prisma, actor.user.id, admission!) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + lastConfirmedAt: confirmedAt, + }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ synchronizationGeneration: source.synchronizationGeneration }) + }) + + it.each([ + { appId: "99999", installationId: "7", mismatch: "App" }, + { appId: "12345", installationId: "8", mismatch: "installation" }, + ])( + "does not use cached access after token creation fails when the source $mismatch ID differs", + async ({ appId, installationId }) => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId, + installationId, + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + }, + ) + + it.each([ + { membershipSource: "manual", expected: "preserved" }, + { membershipSource: "synchronized", expected: "closed" }, + ] as const)( + "fails $expected when stored source credentials cannot be decrypted for a $membershipSource membership", + async ({ membershipSource }) => { + const { prisma, service, store } = await database({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "database", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const actor = await createUser(store, { subject: "42", membershipSource }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await store.lock((records) => + records.saveGitHubIntegrationCredentials("invalid-encrypted-credentials"), + ) + const admission = service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }) + + if (membershipSource === "manual") { + await expect(admission).resolves.toBeNull() + } else { + await expect(admission).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + } + }, + ) + + it("rejects baseline saves from a stale source generation or older confirmation", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const confirmedAt = new Date("2026-10-28T18:00:00.000Z") + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt, + }) + const currentSource = await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { synchronizationGeneration: { increment: 1 } }, + }) + + await expect( + service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 1, + persist: true, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(confirmedAt.getTime() + 1_000), + }), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + await expect( + service.saveAdmissionBaseline(prisma, actor.user.id, { + repositorySourceId: source.id, + sourceGeneration: currentSource.synchronizationGeneration, + subject: actor.identity.subject, + admissionGeneration: 1, + persist: true, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(confirmedAt.getTime() - 1_000), + }), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + mappedPrototypeRole: "editor", + lastConfirmedAt: confirmedAt, + }) + }) + + it.each([ + { + permission: "read", + roleName: "triage", + rawPermission: "triage", + prototypeRole: "commenter", + }, + { + permission: "write", + roleName: "release-manager", + rawPermission: "write", + prototypeRole: "editor", + }, + ] as const)( + "resolves immutable repository metadata and maps role $roleName as $rawPermission", + async ({ permission, roleName, rawPermission, prototypeRole }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const request = vi.fn((input: string | URL | Request): Promise => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "renamed-owner/renamed-repository", + name: "renamed-repository", + owner: { login: "renamed-owner" }, + default_branch: "trunk", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if ( + url.endsWith("/repos/renamed-owner/renamed-repository/collaborators/octocat/permission") + ) { + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: 42 }, + permission, + role_name: roleName, + }), + { status: 200 }, + ), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + await expect( + service.evaluateAdmission({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }), + ).resolves.toMatchObject({ + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + subject: "42", + admissionGeneration: 1, + persist: true, + rawPermission, + prototypeRole, + admissionEligible: true, + }) + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/renamed-owner/renamed-repository/collaborators/octocat/permission", + ]) + }, + ) + + it.each([ + { visibility: "public", collaboratorStatus: 204, admitted: true }, + { visibility: "public", collaboratorStatus: 404, admitted: false }, + { visibility: "internal", collaboratorStatus: 204, admitted: true }, + { visibility: "internal", collaboratorStatus: 404, admitted: false }, + ] as const)( + "$visibility repository admission is $admitted when collaborator membership returns $collaboratorStatus", + async ({ visibility, collaboratorStatus, admitted }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + visibility, + admissionEnabled: true, + }, + }) + const request = vi.fn((input: string | URL | Request): Promise => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility, + }), + { status: 200 }, + ), + ) + } + if (url.endsWith("/repos/owner/repository/collaborators/octocat")) { + return Promise.resolve(new Response(null, { status: collaboratorStatus })) + } + if (url.endsWith("/repos/owner/repository/collaborators/octocat/permission")) { + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { + status: 200, + }), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", request) + + const admission = await service.evaluateAdmission({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + if (admitted) { + expect(admission).toMatchObject({ + repositorySourceId: source.id, + subject: "42", + admissionGeneration: 1, + persist: true, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + }) + } else { + expect(admission).toBeNull() + } + expect(request.mock.calls.map(([input]) => requestUrl(input))).toEqual([ + "https://api.github.com/app/installations/7/access_tokens", + "https://api.github.com/repositories/100", + "https://api.github.com/repos/owner/repository/collaborators/octocat", + ...(admitted + ? ["https://api.github.com/repos/owner/repository/collaborators/octocat/permission"] + : []), + ]) + }, + ) + + it("invalidates a cached grant and session after an immutable repository 404", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + await createUser(store, { + subject: "43", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 404 }), + ), + ), + ) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).resolves.toBeNull() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", admissionEligible: true }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ), + ) + await service.linkRepository("administrator-token", { + githubRepositoryId: source.githubRepositoryId, + admissionEnabled: true, + }) + + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => + Promise.resolve( + requestUrl(input).endsWith("/access_tokens") + ? new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }) + : new Response(null, { status: 503 }), + ), + ), + ) + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + }) + + it("records source configuration mismatch as failed without refreshing confirmation", async () => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const errorLog = vi.spyOn(logger, "error").mockImplementation(() => undefined) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "99999", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "repository_source_configuration_changed", + lastConfirmedAt, + }) + expect(errorLog).toHaveBeenCalledWith( + { + repositorySourceId: source.id, + reason: "repository_source_configuration_changed", + }, + "GitHub authorization reconciliation failed", + ) + }) + + it("records credential reconciliation failure without refreshing confirmation", async () => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "github_503", + lastConfirmedAt, + }) + }) + + it("records a malformed provider response separately from provider availability", async () => { + const { prisma, service } = await database() + await prisma.gitHubIntegrationState.create({ + data: { id: "00000000-0000-4000-8000-000000000001" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 100, full_name: "owner/repository" }), { + status: 200, + }), + ), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "provider_response_invalid", + }) + }) + + it("records an apply failure and propagates the original internal error", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_reconciliation_apply() RETURNS trigger AS $$ + BEGIN + IF NEW.synchronization_status = 'current' THEN + RAISE EXCEPTION 'synthetic reconciliation apply failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_reconciliation_apply + BEFORE UPDATE ON github_repository_sources + FOR EACH ROW EXECUTE FUNCTION reject_reconciliation_apply(); + `) + onTestFinished(async () => { + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS reject_reconciliation_apply ON github_repository_sources", + ) + await prisma.$executeRawUnsafe("DROP FUNCTION IF EXISTS reject_reconciliation_apply()") + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { status: 200 }), + ) + }), + ) + + await expect(service.reconcileRepository("administrator-token", source.id)).rejects.toThrow( + "synthetic reconciliation apply failure", + ) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "apply_failed", + }) + }) + + it("lists repository sources for an administrator without fresh verification", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + await prisma.session.update({ + where: { id: administrator.session!.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 10 * 60_000) }, + }) + + await expect(service.listRepositories("administrator-token")).resolves.toEqual([ + expect.objectContaining({ id: source.id }), + ]) + }) + + it("relinks a repository without changing its existing admission switch", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "old-owner", + name: "old-name", + defaultBranch: "main", + admissionEnabled: true, + status: "removed", + removedAt: new Date(), + }, + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 100, + full_name: "new-owner/new-name", + name: "new-name", + owner: { login: "new-owner" }, + default_branch: "trunk", + visibility: "private", + }), + { status: 200 }, + ), + ), + ) + + await expect( + service.linkRepository("administrator-token", { githubRepositoryId: "100" }), + ).resolves.toMatchObject({ + id: source.id, + status: "active", + admissionEnabled: true, + owner: "new-owner", + name: "new-name", + }) + }) + + it.each([ + { + name: "an inaccessible repository", + response: new Response(null, { status: 404 }), + expected: { status: 422, code: "repository_source_inaccessible" }, + }, + { + name: "a provider authorization failure", + response: new Response(null, { status: 401 }), + expected: { status: 503, code: "source_integration_unavailable" }, + }, + { + name: "a malformed provider response", + response: new Response(JSON.stringify({ id: 100 }), { status: 200 }), + expected: { status: 503, code: "source_integration_unavailable" }, + }, + ])("returns a stable linking error for $name", async ({ response, expected }) => { + const { service, store } = await database() + await createUser(store, { + subject: "42", + role: "administrator", + token: "administrator-token", + }) + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + .mockResolvedValueOnce(response), + ) + + await expect( + service.linkRepository("administrator-token", { githubRepositoryId: "100" }), + ).rejects.toMatchObject(expected) + }) + + it("revokes synchronized access immediately for a deleted repository but preserves compatibility membership", async () => { + const { prisma, service, store } = await database() + const synchronized = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const compatibility = await createUser(store, { + subject: "43", + membershipSource: "legacy-synchronized", + token: "compatibility-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture( + prisma, + service, + synchronized.user.id, + synchronized.identity.subject, + { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }, + ) + + await expect( + service.processWebhook( + webhook( + { + action: "deleted", + installation: { id: 7 }, + repository: { + id: 100, + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }, + }, + "repository", + "deleted-repository", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: synchronized.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect( + await prisma.installationMembership.findUnique({ + where: { id: compatibility.membership.id }, + }), + ).toMatchObject({ source: "legacy-synchronized", status: "active" }) + expect(await prisma.session.findUnique({ where: { id: synchronized.session!.id } })).toBeNull() + expect( + await prisma.session.findUnique({ where: { id: compatibility.session!.id } }), + ).not.toBeNull() + }) + + it("bounds permission requests and isolates an immutable identity mismatch to that identity", async () => { + const { prisma, service, store } = await database() + const users = [] + for (let index = 0; index < 12; index += 1) { + users.push(await createUser(store, { subject: String(1_000 + index) })) + } + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + let activeRequests = 0 + let maximumActiveRequests = 0 + let permissionRequestCount = 0 + const permissionGates: { resolve: () => void }[] = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const subject = url.match(/collaborators\/user-(\d+)\/permission$/)?.[1] + if (!subject) throw new Error(`Unexpected request: ${url}`) + permissionRequestCount += 1 + activeRequests += 1 + maximumActiveRequests = Math.max(maximumActiveRequests, activeRequests) + const gate = deferred() + permissionGates.push({ + resolve: () => { + activeRequests -= 1 + gate.resolve( + new Response( + JSON.stringify({ + user: { id: subject === "1000" ? 999_999 : Number(subject) }, + permission: "read", + }), + { status: 200 }, + ), + ) + }, + }) + return gate.promise + }), + ) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => { + expect(permissionRequestCount).toBe(10) + expect(activeRequests).toBe(10) + }) + expect(maximumActiveRequests).toBe(10) + permissionGates.slice(0, 10).forEach(({ resolve }) => resolve()) + await vi.waitFor(() => expect(permissionRequestCount).toBe(12)) + permissionGates.slice(10).forEach(({ resolve }) => resolve()) + await reconciliation + + expect(maximumActiveRequests).toBe(10) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: users[0]!.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + mappedPrototypeRole: null, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: users[1]!.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + admissionEligible: true, + mappedPrototypeRole: "viewer", + }) + }) + + it("keeps successful confirmations when another identity permission check fails", async () => { + const { prisma, service, store } = await database() + const confirmed = await createUser(store, { subject: "42" }) + const failed = await createUser(store, { subject: "43" }) + const lastConfirmedAt = new Date("2026-10-28T18:00:00.000Z") + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + lastConfirmedAt, + }, + }) + await saveAdmissionFixture(prisma, service, failed.user.id, failed.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "write", + prototypeRole: "editor", + admissionEligible: true, + confirmedAt: lastConfirmedAt, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { + status: 200, + }), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: confirmed.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "read", + admissionEligible: true, + mappedPrototypeRole: "viewer", + status: "active", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: failed.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + mappedPrototypeRole: "editor", + lastConfirmedAt, + status: "active", + }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationStatus: "failed", + synchronizationError: "permission_partial_failure", + lastConfirmedAt, + }) + }) + + it("rolls back first synchronized enrollment when its baseline is superseded", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + }), + ) + const saveBaseline = vi.fn(async (transaction: Prisma.TransactionClient) => { + await expect( + transaction.installationMembership.findUnique({ + where: { id: target.membership.id }, + }), + ).resolves.toMatchObject({ source: "synchronized", status: "active" }) + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed.", + ) + }) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline, + }, + }) + + await expect(authentication.complete("code", "state", "browser-token")).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + + expect(saveBaseline).toHaveBeenCalledOnce() + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: target.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + + it("keeps a newer denied sign-in authoritative when an older grant responds later", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + status: "removed", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock(async (records) => { + for (const suffix of ["older", "newer"]) { + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret(`${suffix}-state`), + browserDigest: digestSecret(`${suffix}-browser`), + pkceVerifier: `${suffix}-verifier`, + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }) + } + }) + const olderPermission = deferred() + const olderPermissionRequested = deferred() + let permissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequests += 1 + if (permissionRequests === 1) { + olderPermissionRequested.resolve() + return olderPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const olderSignIn = authentication.complete("older-code", "older-state", "older-browser") + await olderPermissionRequested.promise + await expect( + authentication.complete("newer-code", "newer-state", "newer-browser"), + ).rejects.toMatchObject({ + status: 403, + code: "admission_denied", + }) + olderPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + await expect(olderSignIn).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + + expect(permissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + + it("keeps a reconciliation denial authoritative when an earlier sign-in responds later", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const signInPermission = deferred() + const signInPermissionRequested = deferred() + let permissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequests += 1 + if (permissionRequests === 1) { + signInPermissionRequested.resolve() + return signInPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const signIn = authentication.complete("code", "state", "browser-token") + await signInPermissionRequested.promise + await service.reconcileDue() + signInPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { status: 200 }), + ) + + await expect(signIn).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect(permissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + + it("does not overwrite an administrator suspension while reconciliation is waiting", async () => { + const { prisma, service, store } = await database() + await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "target-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, target.user.id, target.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const targetPermission = deferred() + const targetPermissionRequested = deferred() + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + targetPermissionRequested.resolve() + return targetPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 41 }, permission: "write" }), { + status: 200, + }), + ) + }), + ) + const administration = createAdministrationService({ store }) + + const reconciliation = service.reconcileDue() + await targetPermissionRequested.promise + await administration.changeMembership("administrator-token", target.membership.id, { + status: "suspended", + }) + targetPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + await reconciliation + + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "manual", status: "suspended" }) + expect(await prisma.session.findUnique({ where: { id: target.session!.id } })).toBeNull() + }) + + it("skips a superseded baseline while still applying another user's permission loss", async () => { + const { prisma, service, store } = await database() + const target = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const lost = await createUser(store, { + subject: "43", + membershipSource: "synchronized", + token: "lost-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, lost.user.id, lost.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const targetPermission = deferred() + const targetPermissionRequested = deferred() + let targetPermissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + if (url.includes("user-42")) { + targetPermissionRequests += 1 + if (targetPermissionRequests === 1) { + targetPermissionRequested.resolve() + return targetPermission.promise + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "write" }), { + status: 200, + }), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 43 }, permission: "none" }), { status: 200 }), + ) + }), + ) + + const reconciliation = service.reconcileDue() + await targetPermissionRequested.promise + const now = new Date() + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("state"), + browserDigest: digestSecret("browser-token"), + pkceVerifier: "pkce-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(now.getTime() + 60_000), + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + await expect(authentication.complete("code", "state", "browser-token")).resolves.toMatchObject({ + purpose: "sign-in", + }) + targetPermission.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "none" }), { status: 200 }), + ) + await reconciliation + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "current", + }) + expect( + await prisma.installationMembership.findUnique({ where: { id: target.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "active" }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "write", + admissionEligible: true, + status: "active", + }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(1) + expect( + await prisma.installationMembership.findUnique({ where: { id: lost.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: lost.session!.id } })).toBeNull() + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: lost.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + }) + + it("rejects a non-ASCII signature with equal character length as unauthorized", async () => { + const { service } = await database() + const input = webhook({}, "ping", "non-ascii-signature") + const signature = `${input.signature.slice(0, -1)}é` + expect(signature).toHaveLength(input.signature.length) + + await expect(service.processWebhook({ ...input, signature })).rejects.toMatchObject({ + status: 401, + code: "invalid_github_webhook", + }) + }) + + it("ignores a signed GitHub ping without an installation payload", async () => { + const { prisma, service } = await database({ + GITHUB_APP_ID: "", + GITHUB_INSTALLATION_ID: "", + GITHUB_PRIVATE_KEY: "", + }) + + await expect( + service.processWebhook( + webhook({ zen: "Keep it logically awesome.", hook_id: 1 }, "ping", "github-ping"), + ), + ).resolves.toBe("ignored") + expect(await prisma.gitHubWebhookDelivery.count()).toBe(0) + }) + + it.each([ + { + name: "installation deletion", + event: "installation", + payload: { action: "deleted", installation: { id: 7 } }, + }, + { + name: "repository removal", + event: "installation_repositories", + payload: { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + }, + ])("removes suspended sources after $name", async ({ event, payload }) => { + const { prisma, service, store } = await database() + await store.lock(() => Promise.resolve()) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + status: "suspended", + synchronizationStatus: "removed", + }, + }) + + await expect( + service.processWebhook(webhook(payload, event, `suspended-${event}-removal`)), + ).resolves.toBe("accepted") + await expect( + service.processWebhook( + webhook( + { action: "unsuspend", installation: { id: 7 } }, + "installation", + `suspended-${event}-later-unsuspend`, + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + }) + + it("enforces synchronized memberships once after removing multiple sources", async () => { + const { prisma, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const sources = await Promise.all( + ["100", "200"].map((githubRepositoryId) => + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId, + appId: "12345", + installationId: "7", + owner: "owner", + name: `repository-${githubRepositoryId}`, + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ), + ) + let membershipEnforcementQueries = 0 + const observedDatabase = prisma.$extends({ + query: { + installationMembership: { + findMany({ args, query }) { + if (args.where?.source === "synchronized" && args.where.status === "active") { + membershipEnforcementQueries += 1 + } + return query(args) + }, + }, + }, + }) + const observedStore = createIdentityStore(observedDatabase as unknown as PrismaClient) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase as unknown as PrismaClient, + environment: environment(), + store: observedStore, + }) + for (const source of sources) { + await saveAdmissionFixture(prisma, observedService, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + } + + await expect( + observedService.processWebhook( + webhook( + { action: "deleted", installation: { id: 7 } }, + "installation", + "multi-source-installation-delete", + ), + ), + ).resolves.toBe("accepted") + + expect(membershipEnforcementQueries).toBe(1) + expect( + await prisma.gitHubRepositorySource.findMany({ + where: { id: { in: sources.map(({ id }) => id) } }, + select: { status: true }, + }), + ).toEqual([{ status: "removed" }, { status: "removed" }]) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }) + + it("excludes fresh eligible users in the initial membership enforcement query", async () => { + const { prisma, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const [removedSource, eligibleSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "removed", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "12345", + installationId: "7", + owner: "owner", + name: "eligible", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + let membershipQueries = 0 + let membershipCandidates = -1 + let baselineLookupQueries = 0 + let membershipWhere: unknown + const observedDatabase = prisma.$extends({ + query: { + installationMembership: { + async findMany({ args, query }) { + membershipQueries += 1 + membershipWhere = args.where + const result = await query(args) + membershipCandidates = result.length + return result + }, + }, + gitHubSynchronizedAccess: { + findFirst({ args, query }) { + baselineLookupQueries += 1 + return query(args) + }, + }, + }, + }) + const observedStore = createIdentityStore(observedDatabase as unknown as PrismaClient) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase as unknown as PrismaClient, + environment: environment(), + store: observedStore, + }) + await saveAdmissionFixture(prisma, observedService, actor.user.id, actor.identity.subject, { + repositorySourceId: eligibleSource.id, + sourceGeneration: eligibleSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + + await expect( + observedService.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: Number(removedSource.githubRepositoryId) }], + }, + "installation_repositories", + "preserve-fresh-eligible-membership", + ), + ), + ).resolves.toBe("accepted") + + expect(membershipQueries).toBe(1) + expect(membershipCandidates).toBe(0) + expect(baselineLookupQueries).toBe(0) + expect(JSON.stringify(membershipWhere)).toContain('"source":"synchronized"') + expect(JSON.stringify(membershipWhere)).toContain('"status":"active"') + expect(JSON.stringify(membershipWhere)).toContain('"admissionEligible":true') + expect(JSON.stringify(membershipWhere)).toContain('"admissionEnabled":true') + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "active" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).not.toBeNull() + }) + + it("suspends linked sources without losing policy and resumes them dirty without baselines", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + + await expect( + service.processWebhook( + webhook( + { action: "suspend", installation: { id: 7 } }, + "installation", + "installation-suspend", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "suspended", + admissionEnabled: true, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + + await expect( + service.processWebhook( + webhook( + { action: "unsuspend", installation: { id: 7 } }, + "installation", + "installation-unsuspend", + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ + status: "active", + admissionEnabled: true, + synchronizationStatus: "dirty", + synchronizationError: null, + removedAt: null, + }) + expect( + await prisma.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed", synchronizationStatus: "removed" }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: actor.user.id } })).toBe(0) + }) + + it("unlinks a suspended source and clears its admission policy and baselines", async () => { + const { prisma, service, store } = await database() + const administrator = await createUser(store, { + subject: "41", + role: "administrator", + token: "administrator-token", + }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + synchronizationStatus: "current", + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await service.processWebhook( + webhook( + { action: "suspend", installation: { id: 7 } }, + "installation", + "installation-suspend-before-unlink", + ), + ) + + await expect( + service.unlinkRepository("administrator-token", source.id), + ).resolves.toBeUndefined() + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + status: "removed", + admissionEnabled: false, + synchronizationStatus: "removed", + }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ + status: "removed", + synchronizationStatus: "removed", + }) + expect( + await prisma.securityAudit.findFirst({ + where: { event: "github.repository.unlink", githubRepositorySourceId: source.id }, + }), + ).toMatchObject({ actorUserId: administrator.user.id }) + }) + + it("revalidates cached fallback in the callback transaction after synchronized revocation", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "existing-session", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("fallback-state"), + browserDigest: digestSecret("fallback-browser"), + pkceVerifier: "fallback-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 503 }))) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: async (profile) => { + const baseline = await service.evaluateAdmission(profile) + await service.processWebhook( + webhook( + { action: "deleted", installation: { id: 7 } }, + "installation", + "fallback-revocation", + ), + ) + return baseline + }, + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + await expect( + authentication.complete("code", "fallback-state", "fallback-browser"), + ).rejects.toMatchObject({ + status: 409, + code: "github_authorization_changed", + }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect(await prisma.session.count({ where: { userId: actor.user.id } })).toBe(0) + }) + + it("removes matching sources and synchronized membership before rejecting token 404 fallback", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "synchronized-session", + }) + const [matchingSource, otherSource] = await Promise.all([ + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "matching", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "200", + appId: "99999", + installationId: "8", + owner: "owner", + name: "other", + defaultBranch: "main", + admissionEnabled: true, + }, + }), + ]) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: matchingSource.id, + sourceGeneration: matchingSource.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 }))) + + await expect( + service.evaluateAdmission({ + subject: actor.identity.subject, + username: actor.identity.username!, + displayName: actor.user.displayName, + avatarUrl: null, + }), + ).rejects.toMatchObject({ + status: 503, + code: "source_integration_unavailable", + }) + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: matchingSource.id } }), + ).toMatchObject({ status: "removed", admissionEnabled: false }) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: otherSource.id } }), + ).toMatchObject({ status: "active", admissionEnabled: true }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: matchingSource.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }) + + it.each([ + { action: "deleted", expectedSourceStatus: "removed" }, + { action: "suspend", expectedSourceStatus: "suspended" }, + ] as const)( + "uses the trusted linked installation for a signed $action webhook when credentials are unreadable", + async ({ action, expectedSourceStatus }) => { + const { prisma, service, store } = await database({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "database", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: `${action}-session`, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await store.lock((records) => + records.saveGitHubIntegrationCredentials("invalid-encrypted-credentials"), + ) + + await expect( + service.processWebhook( + webhook( + { action, installation: { id: 7 } }, + "installation", + `unreadable-credentials-${action}`, + ), + ), + ).resolves.toBe("accepted") + + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ status: expectedSourceStatus }) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: actor.user.id, + }, + }, + }), + ).toMatchObject({ status: "removed" }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: actor.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).toBeNull() + }, + ) + + it("reconciles only GitHub identities with an active installation membership", async () => { + const { prisma, service, store } = await database() + const active = await createUser(store, { subject: "42" }) + await createUser(store, { subject: "43", status: "suspended" }) + await createUser(store, { subject: "44", status: "removed" }) + await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const permissionRequests: string[] = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + permissionRequests.push(url) + return Promise.resolve( + new Response(JSON.stringify({ user: { id: 42 }, permission: "read" }), { status: 200 }), + ) + }), + ) + + await service.reconcileDue() + + expect(permissionRequests).toHaveLength(1) + expect(permissionRequests[0]).toContain(active.identity.username!) + expect(permissionRequests[0]).not.toContain("user-43") + expect(permissionRequests[0]).not.toContain("user-44") + }) + + it("uses each result confirmation time so a later reconciliation denial beats sign-in", async () => { + const { prisma, service, store } = await database() + const users = [] + for (let index = 0; index < 11; index += 1) { + users.push(await createUser(store, { subject: String(100 + index) })) + } + users.sort((left, right) => left.identity.id.localeCompare(right.identity.id)) + const target = users.at(-1)! + await prisma.installationMembership.update({ + where: { id: target.membership.id }, + data: { source: "synchronized" }, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + const provider = await store.read.currentAuthenticationProviderRevision("github") + await store.lock((records) => + records.createAuthorizationAttempt({ + authenticationProviderRevisionId: provider!.id, + originatingSessionId: null, + stateDigest: digestSecret("later-denial-state"), + browserDigest: digestSecret("later-denial-browser"), + pkceVerifier: "later-denial-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const subjectByUsername = new Map( + users.map(({ identity }) => [identity.username!, identity.subject]), + ) + const firstPermissionBatch: Array<{ + subject: string + response: ReturnType> + }> = [] + let targetPermissionRequests = 0 + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const username = decodeURIComponent( + url.match(/collaborators\/([^/]+)\/permission$/)?.[1] ?? "", + ) + const subject = subjectByUsername.get(username) + if (!subject) throw new Error(`Unexpected request: ${url}`) + if (username === target.identity.username) { + targetPermissionRequests += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + user: { id: Number(subject) }, + permission: targetPermissionRequests === 1 ? "write" : "none", + }), + { status: 200 }, + ), + ) + } + const response = deferred() + firstPermissionBatch.push({ subject, response }) + return response.promise + }), + ) + const authentication = createAuthenticationService({ + store, + github: { + authorizationUrl: vi.fn(), + identify: vi.fn().mockResolvedValue({ + subject: target.identity.subject, + username: target.identity.username!, + displayName: target.user.displayName, + avatarUrl: null, + }), + }, + configuration: { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com", + }, + providerRevisionId: provider!.id, + admissionPolicy: { + name: "github-synchronized", + admit: (profile) => service.evaluateAdmission(profile), + saveBaseline: (database, userId, baseline) => + service.saveAdmissionBaseline(database, userId, baseline), + }, + }) + + const reconciliation = service.reconcileDue() + await vi.waitFor(() => expect(firstPermissionBatch).toHaveLength(10)) + const laterSignIn = await authentication.complete( + "code", + "later-denial-state", + "later-denial-browser", + ) + expect(laterSignIn.purpose).toBe("sign-in") + expect(typeof laterSignIn.sessionToken).toBe("string") + firstPermissionBatch.forEach(({ subject, response }) => + response.resolve( + new Response(JSON.stringify({ user: { id: Number(subject) }, permission: "read" }), { + status: 200, + }), + ), + ) + await reconciliation + + expect(targetPermissionRequests).toBe(2) + expect( + await prisma.gitHubSynchronizedAccess.findUniqueOrThrow({ + where: { + repositorySourceId_userId: { + repositorySourceId: source.id, + userId: target.user.id, + }, + }, + }), + ).toMatchObject({ + rawPermission: "none", + admissionEligible: false, + status: "removed", + }) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: target.membership.id }, + }), + ).toMatchObject({ source: "synchronized", status: "removed" }) + expect(await prisma.session.count({ where: { userId: target.user.id } })).toBe(0) + }) + + it("applies confirmed identities in bounded transactions outside the identity lock", async () => { + const { prisma, store } = await database() + for (let index = 0; index < 51; index += 1) { + await createUser(store, { subject: String(1_000 + index) }) + } + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const transactionState = new AsyncLocalStorage<{ baselineWrites: number }>() + let identityLockDepth = 0 + let baselineWritesInsideIdentityLock = 0 + const observedDatabase = prisma.$extends({ + query: { + gitHubSynchronizedAccess: { + upsert({ args, query }) { + const state = transactionState.getStore() + if (state) state.baselineWrites += 1 + if (identityLockDepth > 0) baselineWritesInsideIdentityLock += 1 + return query(args) + }, + }, + }, + }) as unknown as PrismaClient + const originalTransaction = observedDatabase.$transaction.bind(observedDatabase) as unknown as ( + operation: (transaction: Prisma.TransactionClient) => Promise, + ) => Promise + const batchWriteCounts: number[] = [] + vi.spyOn(observedDatabase, "$transaction").mockImplementation( + async (operation: (transaction: Prisma.TransactionClient) => Promise) => { + const state = { baselineWrites: 0 } + const result = await originalTransaction((transaction) => + transactionState.run(state, () => operation(transaction)), + ) + if (state.baselineWrites > 0) batchWriteCounts.push(state.baselineWrites) + return result + }, + ) + const observedStore = createIdentityStore(observedDatabase) + const originalLock = observedStore.lock.bind(observedStore) + vi.spyOn(observedStore, "lock").mockImplementation((operation) => + originalLock(async (...args) => { + identityLockDepth += 1 + try { + return await operation(...args) + } finally { + identityLockDepth -= 1 + } + }), + ) + const observedService = createGitHubAuthorizationService({ + database: observedDatabase, + environment: environment(), + store: observedStore, + }) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + const subject = url.match(/collaborators\/user-(\d+)\/permission$/)?.[1] + if (!subject) throw new Error(`Unexpected request: ${url}`) + return Promise.resolve( + new Response(JSON.stringify({ user: { id: Number(subject) }, permission: "read" }), { + status: 200, + }), + ) + }), + ) + + await observedService.reconcileDue() + + expect(batchWriteCounts).toEqual([50, 1]) + expect(baselineWritesInsideIdentityLock).toBe(0) + expect( + await prisma.gitHubSynchronizedAccess.count({ where: { repositorySourceId: source.id } }), + ).toBe(51) + expect( + await prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).toMatchObject({ + synchronizationGeneration: source.synchronizationGeneration + 1, + synchronizationStatus: "current", + }) + }) + + it("logs a returned partial reconciliation status without requiring a thrown error", async () => { + const { prisma, service, store } = await database() + await createUser(store, { subject: "42" }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + }, + }) + const errorLog = vi.spyOn(logger, "error").mockImplementation(() => undefined) + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response(null, { status: 503 })) + }), + ) + + await expect(service.reconcileDue()).resolves.toBeUndefined() + + expect(errorLog).toHaveBeenCalledWith( + { + repositorySourceId: source.id, + reason: "permission_partial_failure", + }, + "GitHub authorization reconciliation failed", + ) + }) + + it("coalesces repeated dirty webhooks into one bounded reconciliation retry", async () => { + const { prisma, service } = await database() + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + synchronizationStatus: "current", + }, + }) + const repositoryRequested = deferred() + const fetchMock = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input) + if (url.endsWith("/access_tokens")) { + return Promise.resolve( + new Response(JSON.stringify({ token: "installation-token" }), { status: 200 }), + ) + } + if (url.endsWith("/repositories/100")) { + repositoryRequested.resolve() + return Promise.resolve( + new Response( + JSON.stringify({ + id: 100, + full_name: "owner/repository", + name: "repository", + owner: { login: "owner" }, + default_branch: "main", + visibility: "private", + }), + { status: 200 }, + ), + ) + } + throw new Error(`Unexpected request: ${url}`) + }) + vi.stubGlobal("fetch", fetchMock) + for (const deliveryId of ["dirty-one", "dirty-two", "dirty-three"]) { + await expect( + service.processWebhook( + webhook({ action: "edited", installation: { id: 7 } }, "membership", deliveryId), + ), + ).resolves.toBe("accepted") + } + + expect(fetchMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(999) + expect(fetchMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + await repositoryRequested.promise + + expect( + fetchMock.mock.calls.filter(([input]) => requestUrl(input).endsWith("/access_tokens")), + ).toHaveLength(1) + expect( + fetchMock.mock.calls.filter(([input]) => requestUrl(input).endsWith("/repositories/100")), + ).toHaveLength(1) + await vi.waitFor(async () => { + await expect( + prisma.gitHubRepositorySource.findUniqueOrThrow({ where: { id: source.id } }), + ).resolves.toMatchObject({ synchronizationStatus: "current" }) + }) + }) + + it("keeps the synchronized authorization migration constraints and compatibility backfill", () => { + const migration = readFileSync( + new URL( + "../../../../schema/migrations/20261027230000_github_synchronized_authorization/migration.sql", + import.meta.url, + ), + "utf8", + ) + + expect(migration).toMatch( + /CONSTRAINT "github_repository_sources_status" CHECK \(\s*"status" IN \('active', 'removed', 'suspended'\)\s*\)/, + ) + expect(migration).toContain("'legacy-synchronized'") + expect(migration).toMatch( + /UPDATE "installation_memberships"\s+SET "source" = 'legacy-synchronized'\s+WHERE "source" = 'synchronized';/, + ) + }) + + it("rolls back webhook mutations when the required revocation audit fails", async () => { + const { prisma, service, store } = await database() + const actor = await createUser(store, { + subject: "42", + membershipSource: "synchronized", + token: "session-token", + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + admissionEnabled: true, + }, + }) + await saveAdmissionFixture(prisma, service, actor.user.id, actor.identity.subject, { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + rawPermission: "read", + prototypeRole: "viewer", + admissionEligible: true, + confirmedAt: new Date(), + }) + await prisma.$executeRawUnsafe(` + CREATE FUNCTION reject_github_revocation_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = 'membership.synchronized.remove' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await prisma.$executeRawUnsafe(` + CREATE TRIGGER reject_github_revocation_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_github_revocation_audit(); + `) + onTestFinished(async () => { + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS reject_github_revocation_audit ON security_audits", + ) + await prisma.$executeRawUnsafe("DROP FUNCTION IF EXISTS reject_github_revocation_audit()") + }) + + await expect( + service.processWebhook( + webhook( + { + action: "removed", + installation: { id: 7 }, + repositories_removed: [{ id: 100 }], + }, + "installation_repositories", + "delivery-audit-failure", + ), + ), + ).rejects.toThrow("audit unavailable") + + expect( + await prisma.gitHubRepositorySource.findUnique({ where: { id: source.id } }), + ).toMatchObject({ status: "active", admissionEnabled: true }) + expect( + await prisma.installationMembership.findUnique({ where: { id: actor.membership.id } }), + ).toMatchObject({ status: "active" }) + expect(await prisma.session.findUnique({ where: { id: actor.session!.id } })).not.toBeNull() + expect(await prisma.gitHubWebhookDelivery.count()).toBe(0) + }) +}) diff --git a/hub/server/domains/github-authorization/create-service/index.ts b/hub/server/domains/github-authorization/create-service/index.ts new file mode 100644 index 00000000..dee52692 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/index.ts @@ -0,0 +1,1480 @@ +import { createHmac, timingSafeEqual } from "node:crypto" +import { z } from "zod" + +import { createIdentityRecords } from "#server/domains/identity/create-records" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import { getCurrentSession } from "#server/domains/authentication/get-current-session" +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" +import { + mapGitHubPermission, + type GitHubRepositoryPermission, +} from "#server/domains/github-authorization/map-permission" +import type { + GitHubAdmissionBaseline, + GitHubAuthorizationService, + GitHubPrototypeBaseline, +} from "#server/domains/github-authorization/create-service/types" +import { + collaboratorPermissionSchema, + repositorySchema, +} from "#server/integrations/github/check-repository-admission/constants" +import { createInstallationToken } from "#server/integrations/github/create-installation-token" +import { GitHubApiError } from "#server/integrations/github/github-api-error" +import { log } from "#server/integrations/pino/log" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { AuthenticationError } from "#server/utils/authentication-error" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +const policyId = "00000000-0000-0000-0000-000000000001" +const repositoryIdSchema = z.string().regex(/^[1-9]\d{0,19}$/) +const identifierSchema = z.uuid() +const linkSchema = z.object({ + githubRepositoryId: repositoryIdSchema, + admissionEnabled: z.boolean().optional(), +}) +const updateSchema = z.object({ admissionEnabled: z.boolean() }) +const webhookSchema = z.object({ + action: z.string().optional(), + installation: z.object({ id: z.number().int().positive() }), + repository: z + .object({ + id: z.number().int().positive(), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1).optional(), + }) + .optional(), + repositories_added: z.array(z.object({ id: z.number().int().positive() })).optional(), + repositories_removed: z.array(z.object({ id: z.number().int().positive() })).optional(), +}) + +async function lastKnownGoodSeconds( + database: Prisma.TransactionClient | PrismaClient, +): Promise { + return ( + ( + await database.gitHubAuthorizationPolicy.findUnique({ + where: { id: policyId }, + select: { lastKnownGoodSeconds: true }, + }) + )?.lastKnownGoodSeconds ?? 86_400 + ) +} + +function authenticationDependencies(store: IdentityStore) { + return { + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(null) }, + now: () => new Date(), + } +} + +async function currentAdministrator( + store: IdentityStore, + sessionToken: string, + database?: Prisma.TransactionClient, + requireFresh = true, +) { + const actor = await getCurrentSession( + authenticationDependencies(store), + sessionToken, + database ? createIdentityRecords(database) : store.read, + ) + if ( + actor.membership.role !== "administrator" || + (requireFresh && + (!actor.freshAuthorizedAt || Date.now() - actor.freshAuthorizedAt.getTime() >= 5 * 60_000)) + ) { + throw new AuthenticationError( + 403, + actor.membership.role === "administrator" + ? "fresh_authorization_required" + : "installation_administrator_required", + actor.membership.role === "administrator" + ? "Verify your identity again." + : "Installation administrator access is required.", + ) + } + return actor +} + +async function credentials( + store: IdentityStore, + provider: ReturnType, +): Promise { + const value = provider.read(await store.read.githubIntegrationState()) + if (!value) { + throw new AuthenticationError( + 503, + "source_integration_unconfigured", + "GitHub source integration is not configured.", + ) + } + return value +} + +async function permissionFor( + token: string, + username: string, + subject: string, + fullName: string, + visibility: string, +): Promise { + try { + if (visibility !== "private") { + try { + await requestGitHubApi( + `/repos/${fullName}/collaborators/${encodeURIComponent(username)}`, + token, + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return "none" + throw error + } + } + const response = collaboratorPermissionSchema.parse( + await requestGitHubApi( + `/repos/${fullName}/collaborators/${encodeURIComponent(username)}/permission`, + token, + ), + ) + if (String(response.user.id) !== subject) { + return "none" + } + return normalizePermission(response.permission, response.role_name) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return "none" + throw error + } +} + +function normalizePermission( + permission: GitHubRepositoryPermission, + roleName?: string | null, +): GitHubRepositoryPermission { + if (roleName && ["admin", "maintain", "write", "triage", "read"].includes(roleName)) { + return roleName as GitHubRepositoryPermission + } + return permission +} + +async function mapWithConcurrency( + items: T[], + limit: number, + operation: (item: T) => Promise, +): Promise { + const results: R[] = [] + for (let index = 0; index < items.length; index += limit) { + results.push(...(await Promise.all(items.slice(index, index + limit).map(operation)))) + } + return results +} + +async function lockRepository(database: Prisma.TransactionClient, id: string): Promise { + await database.$queryRaw` + SELECT "id" FROM "github_repository_sources" WHERE "id" = ${id}::uuid FOR UPDATE + ` +} + +async function saveBaseline( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + requireAdmissionEnabled = false, + skipOlderConfirmation = false, + requireAdmissionAttempt = true, +): Promise<"saved" | "superseded"> { + await lockRepository(database, baseline.repositorySourceId) + const source = await database.gitHubRepositorySource.findUnique({ + where: { id: baseline.repositorySourceId }, + select: { + status: true, + admissionEnabled: true, + synchronizationGeneration: true, + }, + }) + if ( + !source || + source.status !== "active" || + (requireAdmissionEnabled && !source.admissionEnabled) || + source.synchronizationGeneration !== baseline.sourceGeneration + ) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + if (!baseline.persist) { + const cutoff = new Date(Date.now() - (await lastKnownGoodSeconds(database)) * 1_000) + const cached = await database.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + }) + if ( + !cached || + cached.status !== "active" || + !cached.admissionEligible || + cached.rawPermission !== baseline.rawPermission || + cached.lastConfirmedAt.getTime() !== baseline.confirmedAt.getTime() || + cached.lastConfirmedAt < cutoff + ) { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + return "saved" + } + if (requireAdmissionAttempt) { + const attempt = await database.gitHubAdmissionAttempt.findUnique({ + where: { + repositorySourceId_subject: { + repositorySourceId: baseline.repositorySourceId, + subject: baseline.subject, + }, + }, + select: { generation: true }, + }) + if (!attempt || attempt.generation !== baseline.admissionGeneration) { + if (skipOlderConfirmation) return "superseded" + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + } + const existing = await database.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + select: { lastConfirmedAt: true }, + }) + if (existing && existing.lastConfirmedAt > baseline.confirmedAt) { + if (skipOlderConfirmation) return "superseded" + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + await database.gitHubSynchronizedAccess.upsert({ + where: { + repositorySourceId_userId: { + repositorySourceId: baseline.repositorySourceId, + userId, + }, + }, + create: { + repositorySourceId: baseline.repositorySourceId, + userId, + rawPermission: baseline.rawPermission, + mappedPrototypeRole: baseline.prototypeRole, + admissionEligible: baseline.admissionEligible, + status: baseline.admissionEligible ? "active" : "removed", + synchronizationStatus: baseline.admissionEligible ? "current" : "removed", + lastConfirmedAt: baseline.confirmedAt, + removedAt: baseline.admissionEligible ? null : baseline.confirmedAt, + }, + update: { + rawPermission: baseline.rawPermission, + mappedPrototypeRole: baseline.prototypeRole, + admissionEligible: baseline.admissionEligible, + status: baseline.admissionEligible ? "active" : "removed", + synchronizationStatus: baseline.admissionEligible ? "current" : "removed", + lastConfirmedAt: baseline.confirmedAt, + removedAt: baseline.admissionEligible ? null : baseline.confirmedAt, + }, + }) + return "saved" +} + +async function enforceSynchronizedMemberships( + database: Prisma.TransactionClient, + at: Date, +): Promise { + const cutoff = new Date(at.getTime() - (await lastKnownGoodSeconds(database)) * 1_000) + const synchronizedMemberships = await database.installationMembership.findMany({ + where: { + source: "synchronized", + status: "active", + user: { + githubSynchronizedAccess: { + none: { + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + }, + }, + }, + select: { id: true, userId: true, role: true }, + }) + for (const membership of synchronizedMemberships) { + const [workspaceAdministration, prototypeAdministration] = await Promise.all([ + database.workspaceAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + database.prototypeAccessGrant.findFirst({ + where: { + userId: membership.userId, + effect: "allow", + role: "administrator", + }, + select: { id: true }, + }), + ]) + if (membership.role === "administrator" || workspaceAdministration || prototypeAdministration) { + const preserved = await database.installationMembership.updateMany({ + where: { + id: membership.id, + source: "synchronized", + status: "active", + }, + data: { source: "manual" }, + }) + if (preserved.count > 0) { + await saveSecurityAudit(database, { + event: "membership.synchronized.preserve", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "manual_administration" }, + }) + } + continue + } + const removed = await database.installationMembership.updateMany({ + where: { + id: membership.id, + source: "synchronized", + status: "active", + }, + data: { status: "removed", removedAt: at }, + }) + if (removed.count === 0) continue + await database.session.deleteMany({ where: { userId: membership.userId } }) + await saveSecurityAudit(database, { + event: "membership.synchronized.remove", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + targetMembershipId: membership.id, + context: { reason: "github_access_lost" }, + }) + } +} + +async function removeRepositorySource( + database: Prisma.TransactionClient, + repositorySourceId: string, + removedAt: Date, + enforceMemberships = true, +): Promise { + await lockRepository(database, repositorySourceId) + await database.gitHubRepositorySource.update({ + where: { id: repositorySourceId }, + data: { + status: "removed", + admissionEnabled: false, + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "removed", + removedAt, + }, + }) + await database.gitHubSynchronizedAccess.updateMany({ + where: { repositorySourceId }, + data: { status: "removed", synchronizationStatus: "removed", removedAt }, + }) + if (enforceMemberships) await enforceSynchronizedMemberships(database, removedAt) +} + +async function suspendInstallationSources( + database: Prisma.TransactionClient, + installationId: string, + suspendedAt: Date, +): Promise { + const sources = await database.gitHubRepositorySource.findMany({ + where: { installationId, status: "active" }, + select: { id: true }, + }) + for (const { id } of sources) await lockRepository(database, id) + await database.gitHubRepositorySource.updateMany({ + where: { installationId, status: "active" }, + data: { + status: "suspended", + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "removed", + removedAt: suspendedAt, + }, + }) + await database.gitHubSynchronizedAccess.updateMany({ + where: { repositorySource: { installationId } }, + data: { status: "removed", synchronizationStatus: "removed", removedAt: suspendedAt }, + }) + await enforceSynchronizedMemberships(database, suspendedAt) +} + +async function resumeInstallationSources( + database: Prisma.TransactionClient, + installationId: string, + resumedAt: Date, +): Promise { + const sources = await database.gitHubRepositorySource.findMany({ + where: { installationId, status: "suspended" }, + select: { id: true }, + }) + for (const { id } of sources) await lockRepository(database, id) + await database.gitHubRepositorySource.updateMany({ + where: { installationId, status: "suspended" }, + data: { + status: "active", + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + synchronizationError: null, + removedAt: null, + dirtyAt: resumedAt, + }, + }) +} + +export function createGitHubAuthorizationService({ + database, + environment, + store, +}: { + database: PrismaClient + environment: NodeJS.ProcessEnv + store: IdentityStore +}): GitHubAuthorizationService { + const webhookSecret = environment.HUB_GITHUB_WEBHOOK_SECRET ?? null + if (webhookSecret && (webhookSecret.length < 20 || webhookSecret.length > 1024)) { + throw new Error("HUB_GITHUB_WEBHOOK_SECRET must contain between 20 and 1024 characters.") + } + let reconciliationDueRunning = false + const dirtyRetrySources = new Set() + let dirtyRetryTimer: NodeJS.Timeout | null = null + + async function sourceCredentials() { + return credentials(store, createGitHubIntegrationCredentialProvider(environment)) + } + + function scheduleDirtyReconciliation(sourceIds: string[]) { + for (const sourceId of sourceIds) dirtyRetrySources.add(sourceId) + if (dirtyRetryTimer || dirtyRetrySources.size === 0) return + dirtyRetryTimer = setTimeout(() => { + const sourceIds = [...dirtyRetrySources] + dirtyRetrySources.clear() + dirtyRetryTimer = null + void Promise.allSettled(sourceIds.map((sourceId) => reconcile(sourceId))) + }, 1_000) + dirtyRetryTimer.unref() + } + + async function claimAdmissionGeneration( + repositorySourceId: string, + subject: string, + observedSourceGeneration: number, + requireAdmissionEnabled: boolean, + ) { + return database.$transaction(async (transaction) => { + await lockRepository(transaction, repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || source.status !== "active") return { status: "denied" as const } + if (requireAdmissionEnabled && !source.admissionEnabled) return { status: "denied" as const } + if (source.synchronizationGeneration !== observedSourceGeneration) { + return { status: "changed" as const } + } + const attempt = await transaction.gitHubAdmissionAttempt.upsert({ + where: { repositorySourceId_subject: { repositorySourceId, subject } }, + create: { repositorySourceId, subject, generation: 1 }, + update: { generation: { increment: 1 } }, + }) + return { status: "claimed" as const, source, admissionGeneration: attempt.generation } + }) + } + + async function reconcile(repositorySourceId: string, actorSessionToken?: string) { + const source = await database.$transaction(async (transaction) => { + await lockRepository(transaction, repositorySourceId) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!current || current.status !== "active") { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + + const attemptedAt = new Date() + return transaction.gitHubRepositorySource.update({ + where: { id: current.id }, + data: { + synchronizationGeneration: { increment: 1 }, + lastAttemptedAt: attemptedAt, + synchronizationStatus: "dirty", + synchronizationError: null, + dirtyAt: attemptedAt, + }, + }) + }) + let phase: "credentials" | "token" | "repository" | "permissions" | "apply" = "credentials" + try { + const integration = await sourceCredentials() + if ( + integration.appId !== source.appId || + integration.installationId !== source.installationId + ) { + throw new AuthenticationError( + 409, + "repository_source_configuration_changed", + "The GitHub source integration configuration changed. Relink the repository.", + ) + } + phase = "token" + const token = await createInstallationToken(integration) + phase = "repository" + const repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), + ) + phase = "permissions" + const identities = [] + let identityCursor: string | undefined + while (true) { + const page = await database.externalIdentity.findMany({ + where: { + provider: "github", + issuer: "https://github.com", + username: { not: null }, + user: { memberships: { some: { status: "active" } } }, + }, + select: { id: true, userId: true, subject: true, username: true }, + orderBy: { id: "asc" }, + ...(identityCursor ? { cursor: { id: identityCursor }, skip: 1 } : {}), + take: 100, + }) + identities.push(...page) + if (page.length < 100) break + identityCursor = page.at(-1)?.id + } + const results = await mapWithConcurrency(identities, 10, async (identity) => { + const claim = await claimAdmissionGeneration( + source.id, + identity.subject, + source.synchronizationGeneration, + false, + ) + if (claim.status !== "claimed") return null + try { + const rawPermission = await permissionFor( + token, + identity.username!, + identity.subject, + repository.full_name, + repository.visibility, + ) + return { + status: "confirmed" as const, + userId: identity.userId, + subject: identity.subject, + admissionGeneration: claim.admissionGeneration, + rawPermission, + confirmedAt: new Date(), + ...mapGitHubPermission(rawPermission), + } + } catch { + return { status: "failed" as const } + } + }) + const partialFailure = results.some((result) => result?.status === "failed") + const confirmedAt = results.reduce( + (latest, result) => + result?.status === "confirmed" && + (!latest || result.confirmedAt.getTime() > latest.getTime()) + ? result.confirmedAt + : latest, + null, + ) + phase = "apply" + const actor = actorSessionToken + ? await currentAdministrator(store, actorSessionToken, undefined, false) + : null + const confirmedResults = results.filter( + (result): result is NonNullable & { status: "confirmed" } => + result?.status === "confirmed", + ) + for (let index = 0; index < confirmedResults.length; index += 50) { + const batch = confirmedResults.slice(index, index + 50) + await database.$transaction(async (transaction) => { + await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return + } + for (const result of batch) { + await saveBaseline( + transaction, + result.userId, + { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + subject: result.subject, + admissionGeneration: result.admissionGeneration, + persist: true, + rawPermission: result.rawPermission, + admissionEligible: result.admissionEligible, + prototypeRole: result.prototypeRole, + confirmedAt: result.confirmedAt, + }, + false, + true, + true, + ) + } + }) + } + const applied = await store.lock(async (_records, transaction) => { + await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return false + } + if (actorSessionToken) { + await currentAdministrator(store, actorSessionToken, transaction, false) + } + await transaction.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + visibility: repository.visibility, + status: "active", + removedAt: null, + synchronizationStatus: partialFailure ? "failed" : "current", + synchronizationError: partialFailure ? "permission_partial_failure" : null, + dirtyAt: null, + ...(!partialFailure && confirmedAt ? { lastConfirmedAt: confirmedAt } : {}), + }, + }) + await enforceSynchronizedMemberships(transaction, confirmedAt ?? new Date()) + await saveSecurityAudit(transaction, { + event: "github.repository.reconcile", + actorUserId: actor?.userId ?? null, + sessionId: actor?.id ?? null, + authenticationProviderRevisionId: actor?.authenticationProviderRevisionId ?? null, + githubRepositorySourceId: source.id, + context: { source: "github_app_installation" }, + }) + return true + }) + if (!applied) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } catch (error) { + if ( + error instanceof AuthenticationError && + [ + "fresh_authorization_required", + "installation_administrator_required", + "membership_inactive", + "sign_in_required", + ].includes(error.code) + ) { + throw error + } + if (error instanceof GitHubApiError && error.status === 404) { + const removed = await store.lock(async (_records, transaction) => { + await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + !current || + current.status !== "active" || + current.synchronizationGeneration !== source.synchronizationGeneration + ) { + return false + } + await removeRepositorySource(transaction, source.id, new Date()) + return true + }) + if (!removed) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } + const synchronizationError = + phase === "apply" + ? "apply_failed" + : error instanceof GitHubApiError + ? `github_${error.status}` + : error instanceof AuthenticationError + ? error.code + : ["token", "repository", "permissions"].includes(phase) + ? "provider_response_invalid" + : "provider_unavailable" + let failed + try { + failed = await database.gitHubRepositorySource.updateMany({ + where: { + id: source.id, + status: "active", + synchronizationGeneration: source.synchronizationGeneration, + }, + data: { + synchronizationStatus: "failed", + synchronizationError, + }, + }) + } catch { + throw error + } + if (failed.count === 0) { + return database.gitHubRepositorySource.findUnique({ where: { id: source.id } }) + } + if (phase === "apply") throw error + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + } + + return { + async evaluateAdmission(profile) { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active", admissionEnabled: true }, + orderBy: { id: "asc" }, + }) + if (sources.length === 0) return null + const identity = await database.externalIdentity.findUnique({ + where: { + provider_issuer_subject: { + provider: "github", + issuer: "https://github.com", + subject: profile.subject, + }, + }, + include: { user: { include: { memberships: true } } }, + }) + const cutoff = new Date(Date.now() - (await lastKnownGoodSeconds(database)) * 1_000) + const cached = identity + ? await database.gitHubSynchronizedAccess.findMany({ + where: { + userId: identity.userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySourceId: { in: sources.map(({ id }) => id) }, + }, + }) + : [] + const cachedBySource = new Map( + cached.map((baseline) => [baseline.repositorySourceId, baseline]), + ) + const membership = identity?.user.memberships[0] + let integration: GitHubCredentials + try { + integration = await sourceCredentials() + } catch (error) { + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + throw error instanceof AuthenticationError + ? error + : new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + let token: string + try { + token = await createInstallationToken(integration) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) { + await store.lock(async (_records, transaction) => { + for (const source of sources) { + if ( + source.appId !== integration.appId || + source.installationId !== integration.installationId + ) { + continue + } + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + current?.status === "active" && + current.synchronizationGeneration === source.synchronizationGeneration + ) { + await removeRepositorySource(transaction, source.id, new Date(), false) + } + } + await enforceSynchronizedMemberships(transaction, new Date()) + }) + } else if (error instanceof GitHubApiError) { + for (const source of sources) { + if ( + source.appId !== integration!.appId || + source.installationId !== integration!.installationId + ) { + continue + } + const baseline = cachedBySource.get(source.id) + if (baseline) { + const mapped = mapGitHubPermission( + baseline.rawPermission as GitHubRepositoryPermission, + ) + return { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: 0, + persist: false, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } + } + } + } + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + let providerUnavailable = false + for (const source of sources) { + if ( + source.appId !== integration.appId || + source.installationId !== integration.installationId + ) { + continue + } + try { + const claim = await claimAdmissionGeneration( + source.id, + profile.subject, + source.synchronizationGeneration, + true, + ) + if (claim.status === "changed") { + throw new AuthenticationError( + 409, + "github_authorization_changed", + "GitHub authorization changed. Start again.", + ) + } + if (claim.status === "denied") continue + let repository: z.infer + try { + repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${source.githubRepositoryId}`, token), + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) { + await store.lock(async (_records, transaction) => { + await lockRepository(transaction, source.id) + const current = await transaction.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + }) + if ( + current?.status === "active" && + current.synchronizationGeneration === source.synchronizationGeneration + ) { + await removeRepositorySource(transaction, source.id, new Date()) + } + }) + continue + } + throw error + } + const rawPermission = await permissionFor( + token, + profile.username, + profile.subject, + repository.full_name, + repository.visibility, + ) + const mapped = mapGitHubPermission(rawPermission) + if (mapped.admissionEligible) { + return { + repositorySourceId: source.id, + sourceGeneration: claim.source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: claim.admissionGeneration, + persist: true, + rawPermission, + ...mapped, + confirmedAt: new Date(), + } + } + if (identity) { + await store.lock((_records, transaction) => + saveBaseline(transaction, identity.userId, { + repositorySourceId: source.id, + sourceGeneration: claim.source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: claim.admissionGeneration, + persist: true, + rawPermission, + ...mapped, + confirmedAt: new Date(), + }), + ) + } + } catch (error) { + if (!(error instanceof GitHubApiError)) throw error + providerUnavailable = true + const baseline = cachedBySource.get(source.id) + if (baseline) { + const mapped = mapGitHubPermission(baseline.rawPermission as GitHubRepositoryPermission) + return { + repositorySourceId: source.id, + sourceGeneration: source.synchronizationGeneration, + subject: profile.subject, + admissionGeneration: 0, + persist: false, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + ...mapped, + confirmedAt: baseline.lastConfirmedAt, + } + } + } + } + if (providerUnavailable) { + if (membership?.status === "active" && membership.source !== "synchronized") { + return null + } + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + return null + }, + + async saveAdmissionBaseline(transaction, userId, baseline) { + await saveBaseline(transaction, userId, baseline, true) + }, + + async prototypeBaseline( + transaction, + userId, + prototypeId, + now = new Date(), + ): Promise { + const prototype = await transaction.prototype.findUnique({ + where: { id: prototypeId }, + select: { + authorizationMode: true, + sourceRepositoryId: true, + }, + }) + if (prototype?.authorizationMode !== "github-synchronized" || !prototype.sourceRepositoryId) { + return null + } + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const baseline = await transaction.gitHubSynchronizedAccess.findUnique({ + where: { + repositorySourceId_userId: { + repositorySourceId: prototype.sourceRepositoryId, + userId, + }, + }, + include: { repositorySource: true }, + }) + if ( + !baseline || + baseline.status !== "active" || + !baseline.mappedPrototypeRole || + baseline.lastConfirmedAt < cutoff || + baseline.repositorySource.status !== "active" + ) { + return null + } + return { + role: baseline.mappedPrototypeRole, + repositorySourceId: baseline.repositorySourceId, + githubRepositoryId: baseline.repositorySource.githubRepositoryId, + rawPermission: baseline.rawPermission as GitHubRepositoryPermission, + confirmedAt: baseline.lastConfirmedAt, + synchronizationStatus: baseline.repositorySource.synchronizationStatus, + } + }, + + async prototypeWorkspaceIds(transaction, userId, now = new Date()) { + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const prototypes = await transaction.prototype.findMany({ + where: { + authorizationMode: "github-synchronized", + sourceRepository: { + status: "active", + synchronizedAccess: { + some: { + userId, + status: "active", + mappedPrototypeRole: { not: null }, + lastConfirmedAt: { gte: cutoff }, + }, + }, + }, + }, + select: { workspaceId: true }, + }) + return [...new Set(prototypes.map(({ workspaceId }) => workspaceId))] + }, + + async prototypeIds(transaction, userId, workspaceId, now = new Date()) { + const cutoff = new Date(now.getTime() - (await lastKnownGoodSeconds(transaction)) * 1_000) + const prototypes = await transaction.prototype.findMany({ + where: { + workspaceId, + authorizationMode: "github-synchronized", + grants: { none: { userId, effect: "deny" } }, + sourceRepository: { + status: "active", + synchronizedAccess: { + some: { + userId, + status: "active", + mappedPrototypeRole: { not: null }, + lastConfirmedAt: { gte: cutoff }, + }, + }, + }, + }, + select: { id: true }, + }) + return prototypes.map(({ id }) => id) + }, + + async listRepositories(sessionToken) { + await currentAdministrator(store, sessionToken, undefined, false) + return database.gitHubRepositorySource.findMany({ orderBy: { createdAt: "asc" } }) + }, + + async linkRepository(sessionToken, input) { + const parsed = linkSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_repository_link", + "The repository link is invalid.", + ) + } + await currentAdministrator(store, sessionToken) + let integration: GitHubCredentials + let repository: z.infer + try { + integration = await sourceCredentials() + const token = await createInstallationToken(integration) + repository = repositorySchema.parse( + await requestGitHubApi(`/repositories/${parsed.data.githubRepositoryId}`, token), + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) { + throw new AuthenticationError( + 422, + "repository_source_inaccessible", + "The GitHub repository is not accessible to this installation.", + ) + } + if (error instanceof AuthenticationError) throw error + throw new AuthenticationError( + 503, + "source_integration_unavailable", + "GitHub source integration is unavailable.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + const linked = await transaction.gitHubRepositorySource.upsert({ + where: { githubRepositoryId: String(repository.id) }, + create: { + githubRepositoryId: String(repository.id), + appId: integration.appId, + installationId: integration.installationId, + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + visibility: repository.visibility, + admissionEnabled: parsed.data.admissionEnabled ?? false, + dirtyAt: new Date(), + }, + update: { + appId: integration.appId, + installationId: integration.installationId, + owner: repository.owner.login, + name: repository.name, + defaultBranch: repository.default_branch, + visibility: repository.visibility, + status: "active", + removedAt: null, + ...(parsed.data.admissionEnabled === undefined + ? {} + : { admissionEnabled: parsed.data.admissionEnabled }), + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: new Date(), + }, + }) + await saveSecurityAudit(transaction, { + event: "github.repository.link", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: linked.id, + context: { source: "github_app_installation" }, + }) + return linked + }) + }, + + async updateRepository(sessionToken, repositorySourceId, input) { + const parsed = updateSchema.safeParse(input) + if (!parsed.success || !identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 422, + "invalid_repository_policy", + "The repository policy is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + await lockRepository(transaction, repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || !["active", "suspended"].includes(source.status)) { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + const updated = await transaction.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { admissionEnabled: parsed.data.admissionEnabled }, + }) + await saveSecurityAudit(transaction, { + event: "github.repository.policy", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: source.id, + context: { + reason: parsed.data.admissionEnabled ? "admission_enabled" : "admission_disabled", + }, + }) + return updated + }) + }, + + async unlinkRepository(sessionToken, repositorySourceId) { + if (!identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + await store.lock(async (_records, transaction) => { + const actor = await currentAdministrator(store, sessionToken, transaction) + await lockRepository(transaction, repositorySourceId) + const removedAt = new Date() + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: repositorySourceId }, + }) + if (!source || !["active", "suspended"].includes(source.status)) { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + await removeRepositorySource(transaction, source.id, removedAt) + await saveSecurityAudit(transaction, { + event: "github.repository.unlink", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + githubRepositorySourceId: source.id, + context: { source: "github_app_installation" }, + }) + }) + }, + + async reconcileRepository(sessionToken, repositorySourceId) { + await currentAdministrator(store, sessionToken) + if (!identifierSchema.safeParse(repositorySourceId).success) { + throw new AuthenticationError( + 404, + "repository_source_not_found", + "The repository link was not found.", + ) + } + return reconcile(repositorySourceId, sessionToken) + }, + + async reconcileDue() { + if (reconciliationDueRunning) return + reconciliationDueRunning = true + try { + const sources = await database.gitHubRepositorySource.findMany({ + where: { status: "active" }, + select: { id: true }, + }) + for (const source of sources) { + try { + const reconciled = await reconcile(source.id) + if (reconciled?.synchronizationStatus === "failed") { + log("error", "GitHub authorization reconciliation failed", { + repositorySourceId: source.id, + reason: reconciled.synchronizationError ?? "reconciliation_failed", + }) + } + } catch { + const failed = await database.gitHubRepositorySource.findUnique({ + where: { id: source.id }, + select: { synchronizationError: true }, + }) + log("error", "GitHub authorization reconciliation failed", { + repositorySourceId: source.id, + reason: failed?.synchronizationError ?? "reconciliation_failed", + }) + } + } + await store.lock((_records, transaction) => + enforceSynchronizedMemberships(transaction, new Date()), + ) + } finally { + reconciliationDueRunning = false + } + }, + + async processWebhook({ body, deliveryId, event, signature }) { + if (!webhookSecret) { + throw new AuthenticationError( + 503, + "github_webhook_unconfigured", + "GitHub webhook verification is not configured.", + ) + } + const expected = `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}` + const signatureBuffer = Buffer.from(signature) + const expectedBuffer = Buffer.from(expected) + const valid = + signatureBuffer.length === expectedBuffer.length && + timingSafeEqual(signatureBuffer, expectedBuffer) + if (!valid) { + throw new AuthenticationError( + 401, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + if (!/^[A-Za-z0-9._-]{1,128}$/.test(deliveryId) || !/^[a-z_]{1,64}$/.test(event)) { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + const relevant = new Set([ + "installation", + "installation_repositories", + "repository", + "member", + "membership", + "team_add", + ]) + if (!relevant.has(event)) return "ignored" + let decoded: unknown + try { + decoded = JSON.parse(body.toString("utf8")) + } catch { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + const payload = webhookSchema.safeParse(decoded) + if (!payload.success) { + throw new AuthenticationError( + 422, + "invalid_github_webhook", + "The GitHub webhook is invalid.", + ) + } + const installationId = String(payload.data.installation.id) + try { + const processed = await store.lock(async (_records, transaction) => { + const linkedInstallation = await transaction.gitHubRepositorySource.findFirst({ + where: { installationId }, + select: { id: true }, + }) + if (!linkedInstallation) { + return { result: "ignored" as const, dirtySourceIds: [] } + } + const repositoryId = payload.data.repository?.id + const source = repositoryId + ? await transaction.gitHubRepositorySource.findUnique({ + where: { githubRepositoryId: String(repositoryId) }, + }) + : null + await transaction.gitHubWebhookDelivery.create({ + data: { + deliveryId, + event, + action: payload.data.action ?? null, + installationId, + repositorySourceId: source?.id ?? null, + }, + }) + const now = new Date() + if (event === "installation" && payload.data.action === "deleted") { + const sources = await transaction.gitHubRepositorySource.findMany({ + where: { + installationId, + status: { in: ["active", "suspended"] }, + }, + select: { id: true }, + }) + for (const source of sources) { + await removeRepositorySource(transaction, source.id, now, false) + } + await enforceSynchronizedMemberships(transaction, now) + } else if (event === "installation" && payload.data.action === "suspend") { + await suspendInstallationSources(transaction, installationId, now) + } else if (event === "installation" && payload.data.action === "unsuspend") { + await resumeInstallationSources(transaction, installationId, now) + } else { + const dirtyIds = new Set() + const removedIds = new Set() + if (event === "repository" && payload.data.action === "deleted" && repositoryId) { + removedIds.add(String(repositoryId)) + } + for (const repository of payload.data.repositories_added ?? []) + dirtyIds.add(String(repository.id)) + for (const repository of payload.data.repositories_removed ?? []) + removedIds.add(String(repository.id)) + if (repositoryId) dirtyIds.add(String(repositoryId)) + if (removedIds.size > 0) { + const removedSources = await transaction.gitHubRepositorySource.findMany({ + where: { + githubRepositoryId: { in: [...removedIds] }, + status: { in: ["active", "suspended"] }, + }, + select: { id: true }, + }) + for (const source of removedSources) { + await removeRepositorySource(transaction, source.id, now, false) + } + await enforceSynchronizedMemberships(transaction, now) + } + if (dirtyIds.size > 0) { + await transaction.gitHubRepositorySource.updateMany({ + where: { githubRepositoryId: { in: [...dirtyIds] }, status: "active" }, + data: { + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: now, + }, + }) + } else if (removedIds.size === 0) { + await transaction.gitHubRepositorySource.updateMany({ + where: { installationId, status: "active" }, + data: { + synchronizationGeneration: { increment: 1 }, + synchronizationStatus: "dirty", + dirtyAt: now, + }, + }) + } + } + await saveSecurityAudit(transaction, { + event: "github.webhook.process", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + githubRepositorySourceId: source?.id ?? null, + context: { source: "github_webhook" }, + }) + const dirtySources = await transaction.gitHubRepositorySource.findMany({ + where: { + installationId, + status: "active", + synchronizationStatus: "dirty", + }, + select: { id: true }, + }) + return { + result: "accepted" as const, + dirtySourceIds: dirtySources.map(({ id }) => id), + } + }) + scheduleDirtyReconciliation(processed.dirtySourceIds) + return processed.result + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as { code?: string }).code === "P2002" + ) { + return "duplicate" + } + throw error + } + }, + } +} diff --git a/hub/server/domains/github-authorization/create-service/types.ts b/hub/server/domains/github-authorization/create-service/types.ts new file mode 100644 index 00000000..9c8c6ab1 --- /dev/null +++ b/hub/server/domains/github-authorization/create-service/types.ts @@ -0,0 +1,64 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" +import type { GitHubRepositoryPermission, MappedGitHubPermission } from "../map-permission/index.ts" + +export interface GitHubAdmissionBaseline extends MappedGitHubPermission { + repositorySourceId: string + sourceGeneration: number + subject: string + admissionGeneration: number + persist: boolean + rawPermission: GitHubRepositoryPermission + confirmedAt: Date +} + +export interface GitHubPrototypeBaseline { + role: NonNullable + repositorySourceId: string + githubRepositoryId: string + rawPermission: GitHubRepositoryPermission + confirmedAt: Date + synchronizationStatus: string +} + +export interface GitHubAuthorizationService { + evaluateAdmission(profile: GitHubUserProfile): Promise + saveAdmissionBaseline( + database: Prisma.TransactionClient, + userId: string, + baseline: GitHubAdmissionBaseline, + ): Promise + prototypeBaseline( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + prototypeId: string, + now?: Date, + ): Promise + prototypeWorkspaceIds( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + now?: Date, + ): Promise + prototypeIds( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + workspaceId: string, + now?: Date, + ): Promise + listRepositories(sessionToken: string): Promise + linkRepository(sessionToken: string, input: unknown): Promise + updateRepository( + sessionToken: string, + repositorySourceId: string, + input: unknown, + ): Promise + unlinkRepository(sessionToken: string, repositorySourceId: string): Promise + reconcileRepository(sessionToken: string, repositorySourceId: string): Promise + reconcileDue(): Promise + processWebhook(input: { + deliveryId: string + event: string + signature: string + body: Buffer + }): Promise<"accepted" | "duplicate" | "ignored"> +} diff --git a/hub/server/domains/github-authorization/map-permission/index.test.ts b/hub/server/domains/github-authorization/map-permission/index.test.ts new file mode 100644 index 00000000..4346a22d --- /dev/null +++ b/hub/server/domains/github-authorization/map-permission/index.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" + +import { mapGitHubPermission } from "#server/domains/github-authorization/map-permission" + +describe("mapGitHubPermission()", () => { + it.each([ + ["admin", "editor"], + ["maintain", "editor"], + ["write", "editor"], + ["triage", "commenter"], + ["read", "viewer"], + ] as const)("maps %s access to an admission-eligible %s role", (permission, prototypeRole) => { + expect(mapGitHubPermission(permission)).toEqual({ + admissionEligible: true, + prototypeRole, + }) + }) + + it("denies admission and prototype access when GitHub reports no permission", () => { + expect(mapGitHubPermission("none")).toEqual({ + admissionEligible: false, + prototypeRole: null, + }) + }) +}) diff --git a/hub/server/domains/github-authorization/map-permission/index.ts b/hub/server/domains/github-authorization/map-permission/index.ts new file mode 100644 index 00000000..cf93bb0f --- /dev/null +++ b/hub/server/domains/github-authorization/map-permission/index.ts @@ -0,0 +1,25 @@ +import type { PrototypeRole } from "#server/domains/prototype-authorization/evaluate-access/types" + +export type GitHubRepositoryPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none" + +export interface MappedGitHubPermission { + admissionEligible: boolean + prototypeRole: PrototypeRole | null +} + +export function mapGitHubPermission( + permission: GitHubRepositoryPermission, +): MappedGitHubPermission { + switch (permission) { + case "admin": + case "maintain": + case "write": + return { admissionEligible: true, prototypeRole: "editor" } + case "triage": + return { admissionEligible: true, prototypeRole: "commenter" } + case "read": + return { admissionEligible: true, prototypeRole: "viewer" } + case "none": + return { admissionEligible: false, prototypeRole: null } + } +} diff --git a/hub/server/domains/github-integration/create-credential-provider/index.test.ts b/hub/server/domains/github-integration/create-credential-provider/index.test.ts new file mode 100644 index 00000000..c3a7c6ee --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/index.test.ts @@ -0,0 +1,107 @@ +import { generateKeyPairSync } from "node:crypto" +import { describe, expect, it } from "vitest" + +import { createGitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider" + +function credentials() { + return { + appId: "12345", + installationId: "7", + clientId: "test-client", + clientSecret: "synthetic-client-secret", + privateKey: generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, + }).privateKey, + } +} + +describe("createGitHubIntegrationCredentialProvider()", () => { + it("starts unconfigured and encrypts database credentials with fresh authenticated ciphertext", () => { + const provider = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + }) + const input = credentials() + + expect(provider.name).toBe("database") + expect(provider.read({ generation: 0, encryptedCredentials: null })).toBeNull() + const encrypted = provider.encrypt!(input) + expect(provider.encrypt!(input)).not.toBe(encrypted) + expect(provider.read({ generation: 0, encryptedCredentials: encrypted })).toEqual(input) + expect(encrypted).not.toContain(input.clientSecret) + expect(Buffer.from(encrypted, "base64").toString()).not.toContain(input.privateKey) + + const tampered = Buffer.from(encrypted, "base64") + tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 1 + expect(() => + provider.read({ generation: 0, encryptedCredentials: tampered.toString("base64") }), + ).toThrow() + const otherKey = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_KEY: "cd".repeat(32), + }) + expect(() => otherKey.read({ generation: 0, encryptedCredentials: encrypted })).toThrow() + }) + + it("requires a valid encryption key outside the database", () => { + for (const key of [undefined, "ab".repeat(31), "zz".repeat(32)]) { + expect(() => + createGitHubIntegrationCredentialProvider({ HUB_GITHUB_CREDENTIAL_KEY: key }), + ).toThrow() + } + }) + + it("uses complete environment credentials read-only without consulting database ciphertext", () => { + const input = credentials() + const provider = createGitHubIntegrationCredentialProvider({ + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", + GITHUB_APP_ID: input.appId, + GITHUB_INSTALLATION_ID: input.installationId, + GITHUB_CLIENT_ID: input.clientId, + GITHUB_CLIENT_SECRET: input.clientSecret, + GITHUB_PRIVATE_KEY: input.privateKey, + }) + + expect(provider.name).toBe("environment") + expect(provider.encrypt).toBeUndefined() + expect( + provider.read({ generation: 9, encryptedCredentials: "not-database-credentials" }), + ).toEqual(input) + }) + + it("rejects incomplete environment configuration and unknown providers instead of falling back", () => { + const input = credentials() + const environment = { + HUB_GITHUB_CREDENTIAL_PROVIDER: "environment", + HUB_GITHUB_CREDENTIAL_KEY: "ab".repeat(32), + GITHUB_APP_ID: input.appId, + GITHUB_INSTALLATION_ID: input.installationId, + GITHUB_CLIENT_ID: input.clientId, + GITHUB_CLIENT_SECRET: input.clientSecret, + GITHUB_PRIVATE_KEY: input.privateKey, + } + for (const field of [ + "GITHUB_APP_ID", + "GITHUB_INSTALLATION_ID", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GITHUB_PRIVATE_KEY", + ] as const) { + expect(() => + createGitHubIntegrationCredentialProvider({ ...environment, [field]: undefined }), + ).toThrow() + } + expect(() => + createGitHubIntegrationCredentialProvider({ + ...environment, + GITHUB_PRIVATE_KEY: "invalid", + }), + ).toThrow() + expect(() => + createGitHubIntegrationCredentialProvider({ + ...environment, + HUB_GITHUB_CREDENTIAL_PROVIDER: "other", + }), + ).toThrow() + }) +}) diff --git a/hub/server/domains/github-integration/create-credential-provider/index.ts b/hub/server/domains/github-integration/create-credential-provider/index.ts new file mode 100644 index 00000000..c63c01dd --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/index.ts @@ -0,0 +1,36 @@ +import { decryptGitHubCredentials } from "#server/integrations/github/decrypt-github-credentials" +import { encryptGitHubCredentials } from "#server/integrations/github/encrypt-github-credentials" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { GitHubIntegrationCredentialProvider } from "#server/domains/github-integration/create-credential-provider/types" + +export function createGitHubIntegrationCredentialProvider( + environment: NodeJS.ProcessEnv, +): GitHubIntegrationCredentialProvider { + const selected = environment.HUB_GITHUB_CREDENTIAL_PROVIDER ?? "database" + if (selected === "environment") { + const credentials = validateGitHubCredentials({ + appId: environment.GITHUB_APP_ID, + installationId: environment.GITHUB_INSTALLATION_ID, + clientId: environment.GITHUB_CLIENT_ID, + clientSecret: environment.GITHUB_CLIENT_SECRET, + privateKey: environment.GITHUB_PRIVATE_KEY, + }) + return { name: "environment", read: () => credentials } + } + if (selected !== "database") { + throw new Error("HUB_GITHUB_CREDENTIAL_PROVIDER must be database or environment.") + } + + const encodedKey = environment.HUB_GITHUB_CREDENTIAL_KEY + if (!encodedKey || !/^[a-fA-F0-9]{64}$/.test(encodedKey)) { + throw new Error( + "Set HUB_GITHUB_CREDENTIAL_KEY to a 32-byte hexadecimal encryption key outside the database.", + ) + } + const key = Buffer.from(encodedKey, "hex") + return { + name: "database", + read: (state) => decryptGitHubCredentials(key, state.encryptedCredentials), + encrypt: encryptGitHubCredentials.bind(null, key), + } +} diff --git a/hub/server/domains/github-integration/create-credential-provider/types.ts b/hub/server/domains/github-integration/create-credential-provider/types.ts new file mode 100644 index 00000000..ee46be51 --- /dev/null +++ b/hub/server/domains/github-integration/create-credential-provider/types.ts @@ -0,0 +1,8 @@ +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" + +export interface GitHubIntegrationCredentialProvider { + name: "database" | "environment" + read(state: GitHubIntegrationState): GitHubCredentials | null + encrypt?: (credentials: GitHubCredentials) => string +} diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts new file mode 100644 index 00000000..71087393 --- /dev/null +++ b/hub/server/domains/identity/create-records/index.ts @@ -0,0 +1,103 @@ +import { createAuthenticationProviderRevision } from "#server/repositories/authentication-providers/create-provider-revision" +import { getCurrentAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-current-revision" +import { getLatestAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-latest-revision" +import { getAuthenticationProviderRevision } from "#server/repositories/authentication-providers/get-revision" +import { consumeAuthorizationAttempt } from "#server/repositories/authorization-attempts/consume-attempt" +import { createAuthorizationAttempt } from "#server/repositories/authorization-attempts/create-attempt" +import { getExternalIdentity } from "#server/repositories/external-identities/get-external-identity" +import { getExternalIdentityByDirectoryObjectId } from "#server/repositories/external-identities/get-external-identity-by-directory-object-id" +import { saveExternalIdentity } from "#server/repositories/external-identities/save-external-identity" +import { updateExternalIdentitySubject } from "#server/repositories/external-identities/update-external-identity-subject" +import { advanceGitHubIntegrationGeneration } from "#server/repositories/github-integration-state/advance-generation" +import { hasFreshGitHubAdmission } from "#server/repositories/github-synchronized-access/has-fresh-admission" +import { getGitHubIntegrationState } from "#server/repositories/github-integration-state/get-state" +import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-state/save-credentials" +import { advanceSessionGeneration } from "#server/repositories/identity-store-state/advance-session-generation" +import { completeBootstrap } from "#server/repositories/identity-store-state/complete-bootstrap" +import { getBootstrapCompletedAt } from "#server/repositories/identity-store-state/get-bootstrap-completed-at" +import { getIdentityStoreState } from "#server/repositories/identity-store-state/get-state" +import { countActiveAdministrators } from "#server/repositories/installation-memberships/count-active-administrators" +import { getInstallationMembership } from "#server/repositories/installation-memberships/get-membership" +import { getInstallationMembershipById } from "#server/repositories/installation-memberships/get-membership-by-id" +import { getInstallationMemberships } from "#server/repositories/installation-memberships/get-memberships" +import { saveInstallationMembership } from "#server/repositories/installation-memberships/save-membership" +import { consumeMembershipInvitation } from "#server/repositories/membership-invitations/consume-invitation" +import { createMembershipInvitation } from "#server/repositories/membership-invitations/create-invitation" +import { getMembershipInvitation } from "#server/repositories/membership-invitations/get-invitation" +import { getMembershipInvitations } from "#server/repositories/membership-invitations/get-invitations" +import { getUsableMembershipInvitation } from "#server/repositories/membership-invitations/get-usable-invitation" +import { revokeMembershipInvitation } from "#server/repositories/membership-invitations/revoke-invitation" +import { revokeExpiredMembershipInvitations } from "#server/repositories/membership-invitations/revoke-expired-invitations" +import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" +import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" +import { createSession } from "#server/repositories/sessions/create-session" +import { deleteSession } from "#server/repositories/sessions/delete-session" +import { deleteSessions } from "#server/repositories/sessions/delete-sessions" +import { deleteUserSessions } from "#server/repositories/sessions/delete-user-sessions" +import { getSession } from "#server/repositories/sessions/get-session" +import { getSessionById } from "#server/repositories/sessions/get-session-by-id" +import { invalidateFreshness } from "#server/repositories/sessions/invalidate-freshness" +import { verifySession } from "#server/repositories/sessions/verify-session" +import { createUser } from "#server/repositories/users/create-user" +import { getUser } from "#server/repositories/users/get-user" +import { updateUser } from "#server/repositories/users/update-user" +import type { IdentityRecords } from "#server/domains/identity/create-store/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function createIdentityRecords(database: Prisma.TransactionClient): IdentityRecords { + return { + identityStoreState: getIdentityStoreState.bind(null, database), + bootstrapCompletedAt: getBootstrapCompletedAt.bind(null, database), + advanceSessionGeneration: advanceSessionGeneration.bind(null, database), + completeBootstrap: completeBootstrap.bind(null, database), + githubIntegrationState: getGitHubIntegrationState.bind(null, database), + saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), + advanceGitHubIntegrationGeneration: advanceGitHubIntegrationGeneration.bind(null, database), + hasFreshGitHubAdmission: hasFreshGitHubAdmission.bind(null, database), + user: getUser.bind(null, database), + createUser: createUser.bind(null, database), + updateUser: updateUser.bind(null, database), + externalIdentity: getExternalIdentity.bind(null, database), + externalIdentityByDirectoryObjectId: getExternalIdentityByDirectoryObjectId.bind( + null, + database, + ), + saveExternalIdentity: saveExternalIdentity.bind(null, database), + updateExternalIdentitySubject: updateExternalIdentitySubject.bind(null, database), + membership: getInstallationMembership.bind(null, database), + membershipById: getInstallationMembershipById.bind(null, database), + memberships: getInstallationMemberships.bind(null, database), + activeAdministratorCount: countActiveAdministrators.bind(null, database), + saveMembership: saveInstallationMembership.bind(null, database), + createInvitation: createMembershipInvitation.bind(null, database), + invitation: getMembershipInvitation.bind(null, database), + invitations: getMembershipInvitations.bind(null, database), + usableInvitation: getUsableMembershipInvitation.bind(null, database), + consumeInvitation: consumeMembershipInvitation.bind(null, database), + revokeInvitation: revokeMembershipInvitation.bind(null, database), + revokeExpiredInvitations: revokeExpiredMembershipInvitations.bind(null, database), + createAuthenticationProviderRevision: createAuthenticationProviderRevision.bind(null, database), + currentAuthenticationProviderRevision: getCurrentAuthenticationProviderRevision.bind( + null, + database, + ), + latestAuthenticationProviderRevision: getLatestAuthenticationProviderRevision.bind( + null, + database, + ), + authenticationProviderRevision: getAuthenticationProviderRevision.bind(null, database), + session: getSession.bind(null, database), + sessionById: getSessionById.bind(null, database), + createSession: createSession.bind(null, database), + verifySession: verifySession.bind(null, database), + invalidateFreshness: invalidateFreshness.bind(null, database), + deleteSession: deleteSession.bind(null, database), + deleteSessions: deleteSessions.bind(null, database), + deleteUserSessions: deleteUserSessions.bind(null, database), + createAuthorizationAttempt: createAuthorizationAttempt.bind(null, database), + consumeAuthorizationAttempt: consumeAuthorizationAttempt.bind(null, database), + audit: saveSecurityAudit.bind(null, database), + audits: getSecurityAudits.bind(null, database), + } +} diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts new file mode 100644 index 00000000..013304ee --- /dev/null +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -0,0 +1,791 @@ +import { readdir, readFile } from "node:fs/promises" + +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createIdentityStore } from "#server/domains/identity/create-store" +import { consumeAuthorizationAttempt } from "#server/repositories/authorization-attempts/consume-attempt" + +type IdentityStore = ReturnType + +async function database() { + vi.stubGlobal("prisma", undefined) + const { prisma } = await import("../../../integrations/prisma") + onTestFinished(() => prisma.$disconnect()) + return { prisma, store: createIdentityStore(prisma) } +} + +async function createIdentityGraph(store: IdentityStore, suffix: string) { + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `Synthetic User ${suffix}`, + avatarUrl: null, + }) + const externalIdentity = await records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: `https://issuer-${suffix}.example`, + subject: `subject-${suffix}`, + username: `synthetic-${suffix}`, + displayName: `Synthetic User ${suffix}`, + avatarUrl: null, + }) + const authenticationProviderRevision = await records.createAuthenticationProviderRevision({ + key: `oidc-${suffix}`, + type: "oidc", + enabled: true, + revision: 1, + issuer: `https://issuer-${suffix}.example`, + configuration: { + authorizationEndpoint: `https://issuer-${suffix}.example/authorize`, + clientId: `client-${suffix}`, + }, + }) + return { authenticationProviderRevision, externalIdentity, user } + }) +} + +async function createSession(store: IdentityStore, suffix: string) { + const graph = await createIdentityGraph(store, suffix) + const session = await store.lock((records) => + records.createSession({ + userId: graph.user.id, + externalIdentityId: graph.externalIdentity.id, + authenticationProviderRevisionId: graph.authenticationProviderRevision.id, + tokenDigest: `token-digest-${suffix}`, + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ) + return { ...graph, session } +} + +describe("createIdentityStore()", () => { + it("rolls back GitHub integration, session, and audit changes together", async () => { + const { store } = await database() + const { session } = await createSession(store, "rollback") + await store.lock((records) => + records.saveGitHubIntegrationCredentials("initial-encrypted-fixture"), + ) + + await expect( + store.lock(async (records) => { + await records.saveGitHubIntegrationCredentials("replacement-encrypted-fixture") + await records.advanceGitHubIntegrationGeneration() + await records.deleteSessions() + await records.audit({ + event: "session.revoke_all", + actorUserId: null, + sessionId: null, + authenticationProviderRevisionId: null, + }) + throw new Error("Transaction failed") + }), + ).rejects.toThrow("Transaction failed") + + expect(await store.read.githubIntegrationState()).toMatchObject({ + generation: 0, + encryptedCredentials: "initial-encrypted-fixture", + }) + expect(await store.read.sessionById(session.id)).not.toBeNull() + expect(await store.read.audits()).toEqual([]) + }) + + it("serializes locks so an overlapping mutation reads the committed generation", async () => { + const { store } = await database() + await store.lock(() => Promise.resolve()) + let entered!: () => void + let release!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const first = store.lock(async (records) => { + await records.advanceGitHubIntegrationGeneration() + entered() + await gate + }) + await started + const second = store.lock(async (records) => { + const state = await records.githubIntegrationState() + await records.advanceGitHubIntegrationGeneration() + return state.generation + }) + release() + + await first + expect(await second).toBe(1) + expect((await store.read.githubIntegrationState()).generation).toBe(2) + }) + + it("links multiple issuer-scoped external identities without allowing a relink", async () => { + const { store } = await database() + const firstUser = await store.lock((records) => + records.createUser({ displayName: "First user", avatarUrl: null }), + ) + const secondUser = await store.lock((records) => + records.createUser({ displayName: "Second user", avatarUrl: null }), + ) + const firstIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: firstUser.id, + provider: "oidc", + issuer: "https://first.example", + subject: "shared-subject", + username: "first-name", + displayName: "First identity", + avatarUrl: null, + }), + ) + const secondIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: firstUser.id, + provider: "oidc", + issuer: "https://second.example", + subject: "shared-subject", + username: "second-name", + displayName: "Second identity", + avatarUrl: null, + }), + ) + const relinkAttempt = await store.lock((records) => + records.saveExternalIdentity({ + userId: secondUser.id, + provider: "oidc", + issuer: "https://first.example", + subject: "shared-subject", + username: "updated-name", + displayName: "Updated identity", + avatarUrl: null, + }), + ) + + expect(secondIdentity.id).not.toBe(firstIdentity.id) + expect(secondIdentity.userId).toBe(firstUser.id) + expect(relinkAttempt).toMatchObject({ + id: firstIdentity.id, + userId: firstUser.id, + username: "updated-name", + }) + expect( + await store.read.externalIdentity("oidc", "https://first.example", "shared-subject"), + ).toMatchObject({ id: firstIdentity.id, user: { id: firstUser.id } }) + expect(await store.read.user(firstUser.id)).not.toHaveProperty("administrator") + }) + + it("persists directory object IDs as issuer-scoped unique assignment identifiers", async () => { + const { store } = await database() + const firstUser = await store.lock((records) => + records.createUser({ displayName: "First user", avatarUrl: null }), + ) + const secondUser = await store.lock((records) => + records.createUser({ displayName: "Second user", avatarUrl: null }), + ) + const directoryObjectId = "a0e1d2c3-b4a5-4678-9abc-def012345678" + const firstIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: firstUser.id, + provider: "oidc", + issuer: "https://login.example.test/tenant", + subject: "first-subject", + directoryObjectId, + username: "first@example.test", + displayName: "First user", + avatarUrl: null, + }), + ) + + expect(firstIdentity.directoryObjectId).toBe(directoryObjectId) + await expect( + store.lock((records) => + records.saveExternalIdentity({ + userId: secondUser.id, + provider: "oidc", + issuer: "https://login.example.test/tenant", + subject: "second-subject", + directoryObjectId, + username: "second@example.test", + displayName: "Second user", + avatarUrl: null, + }), + ), + ).rejects.toThrow() + await expect( + store.read.externalIdentityByDirectoryObjectId( + "oidc", + "https://login.example.test/tenant", + directoryObjectId, + ), + ).resolves.toMatchObject({ + id: firstIdentity.id, + subject: "first-subject", + directoryObjectId, + }) + + await expect( + store.lock((records) => + records.saveExternalIdentity({ + userId: secondUser.id, + provider: "oidc", + issuer: "https://login.example.test/other-tenant", + subject: "second-subject", + directoryObjectId, + username: "second@example.test", + displayName: "Second user", + avatarUrl: null, + }), + ), + ).resolves.toMatchObject({ userId: secondUser.id, directoryObjectId }) + }) + + it("persists installation status, role, and source independently from the user", async () => { + const { store } = await database() + const user = await store.lock((records) => + records.createUser({ displayName: "Administrator", avatarUrl: null }), + ) + + await store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "active", + role: "administrator", + source: "bootstrap", + }), + ) + await store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + }), + ) + + expect(await store.read.membership(user.id)).toMatchObject({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + }) + expect(await store.read.user(user.id)).toEqual(user) + expect(user).not.toHaveProperty("administrator") + }) + + it("applies the migration constraint for every current installation membership source", async () => { + const { prisma, store } = await database() + try { + await prisma.$executeRawUnsafe(` + ALTER TABLE "installation_memberships" + ADD CONSTRAINT "installation_memberships_source" + CHECK ("source" IN ('bootstrap', 'invitation', 'manual', 'recovery', 'synchronized')) + `) + const migration = await readFile( + new URL( + "../../../../schema/migrations/20261028210000_oidc_review_hardening/migration.sql", + import.meta.url, + ), + "utf8", + ) + const constraintSql = migration.slice( + migration.indexOf('ALTER TABLE "installation_memberships"'), + ) + await prisma.$executeRawUnsafe(constraintSql) + + for (const source of [ + "bootstrap", + "invitation", + "legacy-synchronized", + "manual", + "oidc-assignment", + "recovery", + "synchronized", + ] as const) { + const user = await store.lock((records) => + records.createUser({ displayName: `Source ${source}`, avatarUrl: null }), + ) + await expect( + store.lock((records) => + records.saveMembership({ + userId: user.id, + status: "active", + role: "member", + source, + }), + ), + ).resolves.toMatchObject({ source }) + } + + const unsupportedUser = await store.lock((records) => + records.createUser({ displayName: "Unsupported source", avatarUrl: null }), + ) + await expect( + prisma.installationMembership.create({ + data: { + userId: unsupportedUser.id, + status: "active", + role: "member", + source: "provider-default", + }, + }), + ).rejects.toThrow() + } finally { + await prisma.$executeRawUnsafe(` + ALTER TABLE "installation_memberships" + DROP CONSTRAINT IF EXISTS "installation_memberships_source" + `) + } + }) + + it("introduces OIDC identity columns only in the final hardening migration", async () => { + const migrationsUrl = new URL("../../../../schema/migrations/", import.meta.url) + const migrationDirectories = (await readdir(migrationsUrl, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + const migrationNames = [] + const migrationsWithOidcIdentityColumns = [] + + for (const migrationName of migrationDirectories) { + const migrationUrl = new URL(`${migrationName}/`, migrationsUrl) + if (!(await readdir(migrationUrl)).includes("migration.sql")) continue + migrationNames.push(migrationName) + const sql = await readFile(new URL("migration.sql", migrationUrl), "utf8") + if (sql.includes('ADD COLUMN "email"') || sql.includes('ADD COLUMN "directory_object_id"')) { + migrationsWithOidcIdentityColumns.push(migrationName) + } + } + migrationNames.sort() + + expect(migrationsWithOidcIdentityColumns).toEqual(["20261028210000_oidc_review_hardening"]) + expect(migrationNames).not.toContain("20261027221500_oidc_identity_attributes") + }) + + it("stores OIDC provider revisions separately from GitHub integration credentials", async () => { + const { prisma, store } = await database() + const revision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "workforce-oidc", + type: "oidc", + enabled: true, + revision: 7, + issuer: "https://login.example", + configuration: { + authorizationEndpoint: "https://login.example/authorize", + clientId: "replica-hub", + }, + }), + ) + + expect(revision).toMatchObject({ + revision: 7, + issuer: "https://login.example", + configuration: { + authorizationEndpoint: "https://login.example/authorize", + clientId: "replica-hub", + }, + authenticationProvider: { + key: "workforce-oidc", + type: "oidc", + enabled: true, + }, + }) + expect(await store.read.githubIntegrationState()).toMatchObject({ + generation: 0, + encryptedCredentials: null, + }) + + await expect( + store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "workforce-oidc", + type: "oidc", + enabled: true, + revision: 7, + issuer: "https://replacement.example", + configuration: { clientId: "replacement-client" }, + }), + ), + ).rejects.toThrow() + await store.lock((records) => + records.saveGitHubIntegrationCredentials("encrypted-github-credentials"), + ) + + expect( + await prisma.authenticationProviderRevision.findUnique({ where: { id: revision.id } }), + ).toMatchObject({ + issuer: "https://login.example", + configuration: revision.configuration, + }) + }) + + it("keeps sessions tied to identity revisions when GitHub credentials rotate", async () => { + const { store } = await database() + const { authenticationProviderRevision, externalIdentity, session, user } = await createSession( + store, + "credential-rotation", + ) + + await store.lock(async (records) => { + await records.saveGitHubIntegrationCredentials("rotated-encrypted-credentials") + await records.advanceGitHubIntegrationGeneration() + }) + + expect(await store.read.session(session.tokenDigest)).toMatchObject({ + id: session.id, + user: { id: user.id }, + externalIdentity: { id: externalIdentity.id }, + authenticationProviderRevision: { + id: authenticationProviderRevision.id, + authenticationProvider: { key: "oidc-credential-rotation", type: "oidc" }, + }, + }) + }) + + it("rejects a session when the external identity provider type differs", async () => { + const { store } = await database() + const user = await store.lock((records) => + records.createUser({ displayName: "Provider mismatch", avatarUrl: null }), + ) + const externalIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://login.example", + subject: "provider-mismatch", + username: "provider-mismatch", + displayName: "Provider mismatch", + avatarUrl: null, + }), + ) + const authenticationProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "provider-mismatch", + type: "oidc", + enabled: true, + revision: 1, + issuer: "https://login.example", + configuration: { clientId: "provider-mismatch" }, + }), + ) + + await expect( + store.lock((records) => + records.createSession({ + userId: user.id, + externalIdentityId: externalIdentity.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + tokenDigest: "provider-mismatch-token", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ), + ).rejects.toThrow("Session external identity provider must match") + expect(await store.read.session("provider-mismatch-token")).toBeNull() + }) + + it("rejects a session when the external identity issuer differs", async () => { + const { store } = await database() + const user = await store.lock((records) => + records.createUser({ displayName: "Issuer mismatch", avatarUrl: null }), + ) + const externalIdentity = await store.lock((records) => + records.saveExternalIdentity({ + userId: user.id, + provider: "oidc", + issuer: "https://identity.example", + subject: "issuer-mismatch", + username: "issuer-mismatch", + displayName: "Issuer mismatch", + avatarUrl: null, + }), + ) + const authenticationProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "issuer-mismatch", + type: "oidc", + enabled: true, + revision: 1, + issuer: "https://provider.example", + configuration: { clientId: "issuer-mismatch" }, + }), + ) + + await expect( + store.lock((records) => + records.createSession({ + userId: user.id, + externalIdentityId: externalIdentity.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + tokenDigest: "issuer-mismatch-token", + signedInAt: new Date(), + freshAuthorizedAt: new Date(), + }), + ), + ).rejects.toThrow("Session external identity issuer must match") + expect(await store.read.session("issuer-mismatch-token")).toBeNull() + }) + + it("keeps a provider type and historical session context after rejecting a type change", async () => { + const { store } = await database() + const { authenticationProviderRevision, session } = await createSession( + store, + "immutable-provider-type", + ) + + await expect( + store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "oidc-immutable-provider-type", + type: "github", + enabled: false, + revision: 2, + issuer: "https://github.com", + configuration: { clientId: "replacement-client" }, + }), + ), + ).rejects.toThrow("Authentication provider type cannot change") + + expect(await store.read.session(session.tokenDigest)).toMatchObject({ + authenticationProviderRevision: { + id: authenticationProviderRevision.id, + revision: 1, + authenticationProvider: { + key: "oidc-immutable-provider-type", + type: "oidc", + enabled: true, + }, + }, + }) + }) + + it("deletes an attempt expiring at the database transaction time without consuming it", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision } = await createIdentityGraph(store, "expiry-boundary") + const stateDigest = "expiry-boundary-state" + + const consumed = await prisma.$transaction(async (database) => { + const [clock] = await database.$queryRaw<{ now: Date }[]>` + SELECT CURRENT_TIMESTAMP AS now + ` + if (!clock) throw new Error("Expected the database transaction time") + await database.authorizationAttempt.create({ + data: { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: null, + stateDigest, + browserDigest: "expiry-boundary-browser", + pkceVerifier: "expiry-boundary-verifier", + nonce: null, + purpose: "sign-in", + expiresAt: clock.now, + }, + }) + return consumeAuthorizationAttempt(database, stateDigest) + }) + + expect(consumed).toBeNull() + expect(await prisma.authorizationAttempt.findUnique({ where: { stateDigest } })).toBeNull() + }) + + it("rejects an attempt that expires while its consumer waits for the store lock", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision } = await createIdentityGraph(store, "waiting-expiry") + const stateDigest = "waiting-expiry-state" + await store.lock(() => Promise.resolve()) + let announceAttempt!: (expiresAt: Date) => void + let releaseLock!: () => void + const attemptCreated = new Promise((resolve) => { + announceAttempt = resolve + }) + const lockGate = new Promise((resolve) => { + releaseLock = resolve + }) + const holder = store.lock(async (records) => { + const expiresAt = new Date(Date.now() + 1_500) + await records.createAuthorizationAttempt({ + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: null, + stateDigest, + browserDigest: "waiting-expiry-browser", + pkceVerifier: "waiting-expiry-verifier", + nonce: null, + purpose: "sign-in", + expiresAt, + }) + announceAttempt(expiresAt) + await lockGate + }) + const expiresAt = await attemptCreated + let consumerEntered = false + const consumer = store.lock(async (records) => { + consumerEntered = true + return records.consumeAuthorizationAttempt(stateDigest) + }) + onTestFinished(async () => { + releaseLock() + await Promise.allSettled([holder, consumer]) + }) + + await vi.waitFor( + async () => { + const [waiting] = await prisma.$queryRaw< + { transactionStartedAt: Date; waitEventType: string | null }[] + >` + SELECT + xact_start AS "transactionStartedAt", + wait_event_type AS "waitEventType" + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND state = 'active' + AND query LIKE '%identity_store_states%' + AND wait_event_type = 'Lock' + ORDER BY xact_start DESC + LIMIT 1 + ` + expect(waiting?.waitEventType).toBe("Lock") + expect(waiting?.transactionStartedAt.getTime()).toBeLessThan(expiresAt.getTime()) + }, + { interval: 20, timeout: 5_000 }, + ) + await vi.waitFor( + async () => { + const [clock] = await prisma.$queryRaw<{ expired: boolean }[]>` + SELECT clock_timestamp() >= ${expiresAt} AS expired + ` + expect(clock?.expired).toBe(true) + }, + { interval: 20, timeout: 5_000 }, + ) + expect(consumerEntered).toBe(false) + releaseLock() + + await holder + expect(await consumer).toBeNull() + expect(await prisma.authorizationAttempt.findUnique({ where: { stateDigest } })).toBeNull() + }) + + it("prunes expired provider-aware attempts and atomically consumes a live attempt once", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision, session } = await createSession(store, "attempt") + const otherProviderRevision = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "other-oidc", + type: "oidc", + enabled: true, + revision: 2, + issuer: "https://other.example", + configuration: { clientId: "other-client" }, + }), + ) + const expired = { + authenticationProviderRevisionId: otherProviderRevision.id, + originatingSessionId: null, + stateDigest: "expired-state-digest", + browserDigest: "expired-browser-digest", + pkceVerifier: "expired-pkce-verifier", + nonce: null, + purpose: "sign-in" as const, + expiresAt: new Date(Date.now() - 1), + } + const live = { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: session.id, + stateDigest: "live-state-digest", + browserDigest: "live-browser-digest", + pkceVerifier: "live-pkce-verifier", + nonce: "live-oidc-nonce", + purpose: "link-identity" as const, + expiresAt: new Date(Date.now() + 15 * 60_000), + } + await store.lock((records) => records.createAuthorizationAttempt(expired)) + await store.lock((records) => records.createAuthorizationAttempt(live)) + + expect( + await prisma.authorizationAttempt.findUnique({ + where: { stateDigest: expired.stateDigest }, + }), + ).toBeNull() + const results = await Promise.all([ + prisma.$transaction((database) => consumeAuthorizationAttempt(database, live.stateDigest)), + prisma.$transaction((database) => consumeAuthorizationAttempt(database, live.stateDigest)), + ]) + + expect(results.filter(Boolean)).toHaveLength(1) + expect(results.find(Boolean)).toMatchObject(live) + expect( + await prisma.authorizationAttempt.findUnique({ + where: { stateDigest: live.stateDigest }, + }), + ).toBeNull() + }) + + it("removes pending attempts with their originating session", async () => { + const { store } = await database() + const { authenticationProviderRevision, session } = await createSession(store, "sign-out") + const attempt = { + authenticationProviderRevisionId: authenticationProviderRevision.id, + originatingSessionId: session.id, + stateDigest: "originating-session-state", + browserDigest: "originating-session-browser", + pkceVerifier: "originating-session-verifier", + nonce: null, + purpose: "verify" as const, + expiresAt: new Date(Date.now() + 15 * 60_000), + } + await store.lock((records) => records.createAuthorizationAttempt(attempt)) + + await store.lock((records) => records.invalidateFreshness()) + expect(await store.read.sessionById(session.id)).toMatchObject({ freshAuthorizedAt: null }) + await store.lock((records) => records.deleteSession(session.id)) + + expect(await store.read.session(session.tokenDigest)).toBeNull() + expect( + await store.lock((records) => records.consumeAuthorizationAttempt(attempt.stateDigest)), + ).toBeNull() + }) + + it("retains minimal structured audits for ninety days and prunes older records on writes", async () => { + const { prisma, store } = await database() + const { authenticationProviderRevision, session, user } = await createSession(store, "audit") + const now = Date.now() + vi.spyOn(Date, "now").mockReturnValue(now) + const cutoff = now - 90 * 24 * 60 * 60_000 + const old = await prisma.securityAudit.create({ + data: { + event: "identity.denied.request_failed", + createdAt: new Date(cutoff - 1), + }, + }) + const retained = await prisma.securityAudit.create({ + data: { + event: "session.sign_in", + createdAt: new Date(cutoff), + }, + }) + + expect((await store.read.audits()).map(({ id }) => id)).toEqual([retained.id]) + expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).not.toBeNull() + await store.lock((records) => + records.audit({ + event: "identity.verified", + actorUserId: user.id, + sessionId: session.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + }), + ) + + expect(await prisma.securityAudit.findUnique({ where: { id: old.id } })).toBeNull() + expect(await prisma.securityAudit.findUnique({ where: { id: retained.id } })).not.toBeNull() + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "identity.verified", + actorUserId: user.id, + sessionId: session.id, + authenticationProviderRevisionId: authenticationProviderRevision.id, + }), + expect.objectContaining({ id: retained.id, event: "session.sign_in" }), + ]) + expect(await prisma.securityAudit.count()).toBe(2) + }) +}) diff --git a/hub/server/domains/identity/create-store/index.ts b/hub/server/domains/identity/create-store/index.ts new file mode 100644 index 00000000..73da355b --- /dev/null +++ b/hub/server/domains/identity/create-store/index.ts @@ -0,0 +1,12 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { withIdentityStoreLock } from "#server/domains/identity/with-store-lock" +import type { IdentityStore } from "#server/domains/identity/create-store/types" + +import type { PrismaClient } from "../../../generated/prisma/client.ts" + +export function createIdentityStore(client: PrismaClient): IdentityStore { + return { + read: createIdentityRecords(client), + lock: (operation) => withIdentityStoreLock(client, operation), + } +} diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts new file mode 100644 index 00000000..435c9f5b --- /dev/null +++ b/hub/server/domains/identity/create-store/types.ts @@ -0,0 +1,127 @@ +import type { AuthenticationProviderInput } from "#server/repositories/authentication-providers/types" +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" +import type { + ExternalIdentityInput, + HubExternalIdentity, +} from "#server/repositories/external-identities/types" +import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" +import type { + InstallationMembershipDetails, + InstallationMembershipInput, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" +import type { + MembershipInvitationInput, + MembershipInvitationRecord, +} 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 { HubUser, UserInput } from "#server/repositories/users/types" +import type { IdentityStoreState } from "#server/repositories/identity-store-state/types" +import type { Prisma } from "../../../generated/prisma/client.ts" + +export interface AuthenticationProviderRevisionRecord { + id: string + authenticationProviderId: string + revision: number + issuer: string + configuration: unknown + authenticationProvider: { + id: string + key: string + type: string + enabled: boolean + } +} + +export interface SessionContext extends HubSession { + user: HubUser + externalIdentity: HubExternalIdentity + authenticationProviderRevision: AuthenticationProviderRevisionRecord +} + +export interface IdentityRecords { + identityStoreState(): Promise + bootstrapCompletedAt(): Promise + advanceSessionGeneration(): Promise + completeBootstrap(completedAt: Date): Promise + githubIntegrationState(): Promise + saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise + advanceGitHubIntegrationGeneration(): Promise + hasFreshGitHubAdmission(userId: string, now: Date): Promise + user(id: string): Promise + createUser(input: UserInput): Promise + updateUser(id: string, input: UserInput): Promise + externalIdentity( + provider: string, + issuer: string, + subject: string, + ): Promise<(HubExternalIdentity & { user: HubUser }) | null> + externalIdentityByDirectoryObjectId( + provider: string, + issuer: string, + directoryObjectId: string, + ): Promise<(HubExternalIdentity & { user: HubUser }) | null> + saveExternalIdentity( + input: ExternalIdentityInput, + ): Promise + updateExternalIdentitySubject( + id: string, + subject: string, + ): Promise + membership(userId: string): Promise + membershipById(id: string): Promise + memberships(): Promise + activeAdministratorCount(): Promise + saveMembership(input: InstallationMembershipInput): Promise + createInvitation(input: MembershipInvitationInput): Promise + invitation(id: string): Promise + invitations(): Promise + usableInvitation( + provider: string, + issuer: string, + subject: string, + now: Date, + ): Promise + consumeInvitation( + id: string, + activatedIdentityId: string, + activatedMembershipId: string, + consumedAt: Date, + ): Promise + revokeInvitation(id: string, revokedAt: Date): Promise + revokeExpiredInvitations(revokedAt: Date): Promise + createAuthenticationProviderRevision( + input: AuthenticationProviderInput, + ): Promise + currentAuthenticationProviderRevision( + key: string, + ): Promise + latestAuthenticationProviderRevision( + key: string, + ): Promise + authenticationProviderRevision(id: string): Promise + session(tokenDigest: string): Promise + sessionById(id: string): Promise + createSession(input: Omit): Promise + verifySession(id: string, at: Date): Promise + invalidateFreshness(): Promise + deleteSession(id: string): Promise + deleteSessions(): Promise + deleteUserSessions(userId: string): Promise + createAuthorizationAttempt( + input: Omit & { + sessionGeneration?: number + }, + ): Promise + consumeAuthorizationAttempt(stateDigest: string): Promise + audit(input: AuditInput): Promise + audits(): Promise +} + +export interface IdentityStore { + read: IdentityRecords + 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 new file mode 100644 index 00000000..1ed20b19 --- /dev/null +++ b/hub/server/domains/identity/with-store-lock/index.ts @@ -0,0 +1,16 @@ +import { createIdentityRecords } from "#server/domains/identity/create-records" +import { ensureGitHubIntegrationState } from "#server/repositories/github-integration-state/ensure-state" +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 { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityStoreLock( + client: PrismaClient, + operation: (records: IdentityRecords, database: Prisma.TransactionClient) => Promise, +): Promise { + return withRepositoryLock(client, async (database) => { + await ensureGitHubIntegrationState(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..24574036 --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/index.db.test.ts @@ -0,0 +1,3063 @@ +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()) + const store = createIdentityStore(prisma) + return { + prisma, + store, + service: createPrototypeAuthorizationService({ + baseline, + database: prisma, + store, + }), + } +} + +async function createActor( + store: IdentityStore, + { + subject, + token, + role = "member", + source = "manual", + }: { + subject: string + token: string + role?: "member" | "administrator" + source?: "manual" | "synchronized" + }, +) { + 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, + }) + 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 completesWithin(promise: Promise, milliseconds: number) { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), milliseconds) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +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 } +} + +async function createAdministeredPrototype({ + service, + store, + subject, + token, +}: { + service: ReturnType + store: IdentityStore + subject: string + token: string +}) { + const actor = await createActor(store, { subject, token, role: "administrator" }) + const workspace = (await service.createWorkspace(token, { + name: `${subject} workspace`, + })) as { id: string } + const prototype = (await service.createPrototype(token, workspace.id, { + name: `${subject} prototype`, + })) as { id: string } + return { actor, prototype, workspace } +} + +async function rejectAudit( + database: PrismaClient, + event: + | "prototype.publisher.create" + | "prototype.publisher.disable" + | "prototype.publisher.credential.revoke", + action: () => Promise, +) { + await database.$executeRawUnsafe(` + CREATE FUNCTION reject_test_publisher_audit() RETURNS trigger AS $$ + BEGIN + IF NEW.event = '${event}' THEN + RAISE EXCEPTION 'audit unavailable'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `) + await database.$executeRawUnsafe(` + CREATE TRIGGER reject_test_publisher_audit + BEFORE INSERT ON security_audits + FOR EACH ROW EXECUTE FUNCTION reject_test_publisher_audit(); + `) + try { + await expect(action()).rejects.toThrow("audit unavailable") + } finally { + await database.$executeRawUnsafe("DROP TRIGGER reject_test_publisher_audit ON security_audits") + await database.$executeRawUnsafe("DROP FUNCTION reject_test_publisher_audit()") + } +} + +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("enforces fresh prototype administration and saves authorization mode changes with audits", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "authorization-administrator", + token: "authorization-administrator-token", + role: "administrator", + }) + const workspace = (await service.createWorkspace("authorization-administrator-token", { + name: "Workspace", + })) as { id: string } + const prototype = (await service.createPrototype( + "authorization-administrator-token", + workspace.id, + { name: "Prototype" }, + )) as { + id: string + authorizationMode: string + sourceRepositoryId: string | null + } + expect(prototype).toMatchObject({ + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }) + const source = await prisma.gitHubRepositorySource.create({ + data: { + githubRepositoryId: "100", + appId: "12345", + installationId: "7", + owner: "owner", + name: "repository", + defaultBranch: "main", + status: "suspended", + }, + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + }) + await prisma.session.update({ + where: { id: actor.session.id }, + data: { freshAuthorizedAt: new Date() }, + }) + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).rejects.toMatchObject({ + status: 422, + code: "repository_source_unavailable", + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }, + ) + expect( + await prisma.securityAudit.count({ + where: { event: "prototype.authorization.policy", prototypeId: prototype.id }, + }), + ).toBe(0) + + await prisma.gitHubRepositorySource.update({ + where: { id: source.id }, + data: { status: "active", removedAt: null }, + }) + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "github-synchronized", + repositorySourceId: source.id, + }), + ).resolves.toMatchObject({ + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }) + await expect( + service.getPrototype("authorization-administrator-token", prototype.id), + ).resolves.toMatchObject({ + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }) + await expect( + service.listPrototypes("authorization-administrator-token", workspace.id, {}), + ).resolves.toMatchObject({ + items: [ + expect.objectContaining({ + id: prototype.id, + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }), + ], + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "github-synchronized", + sourceRepositoryId: source.id, + }, + ) + + await expect( + service.setPrototypeAuthorization("authorization-administrator-token", prototype.id, { + mode: "hub-managed", + }), + ).resolves.toMatchObject({ + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }) + expect(await prisma.prototype.findUniqueOrThrow({ where: { id: prototype.id } })).toMatchObject( + { + authorizationMode: "hub-managed", + sourceRepositoryId: null, + }, + ) + expect( + await prisma.securityAudit.findMany({ + where: { event: "prototype.authorization.policy", prototypeId: prototype.id }, + orderBy: { createdAt: "asc" }, + select: { + actorUserId: true, + githubRepositorySourceId: true, + context: true, + }, + }), + ).toEqual([ + { + actorUserId: actor.user.id, + githubRepositorySourceId: source.id, + context: { reason: "github_synchronized" }, + }, + { + actorUserId: actor.user.id, + githubRepositorySourceId: null, + context: { reason: "hub_managed" }, + }, + ]) + }) + + it("rejects workspace creation by a member without writing product state", async () => { + const { prisma, service, store } = await database() + await createActor(store, { + 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 publisher" }] 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.createPublisherCredential(token, prototype.id, { + name: "Rejected publisher", + actions: ["publish"], + }) + + 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("audits publisher disable, re-enable, and credential revocation with their state changes", async () => { + const { prisma, service, store } = await database() + const token = "publisher-lifecycle-token" + const { actor, prototype } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-lifecycle", + token, + }) + + await rejectAudit(prisma, "prototype.publisher.create", () => + service.createPublisherCredential(token, prototype.id, { + name: "Rejected publisher", + actions: ["publish"], + }), + ) + expect(await prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } })).toBe(0) + + const provisioned = (await service.createPublisherCredential(token, prototype.id, { + name: "CI publisher", + actions: ["publish", "read-status"], + })) as { + publisher: { id: string } + credential: { id: string } + } + + await rejectAudit(prisma, "prototype.publisher.disable", () => + service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: false, + }), + ) + expect( + await prisma.workloadPublisher.findUniqueOrThrow({ + where: { id: provisioned.publisher.id }, + select: { active: true }, + }), + ).toEqual({ active: true }) + + await service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: false, + }) + await service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: true, + }) + await rejectAudit(prisma, "prototype.publisher.credential.revoke", () => + service.revokePublisherCredential( + token, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ), + ) + expect( + await prisma.workloadCredential.findUniqueOrThrow({ + where: { id: provisioned.credential.id }, + select: { revokedAt: true }, + }), + ).toEqual({ revokedAt: null }) + + await service.revokePublisherCredential( + token, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ) + + const revokedCredential = await prisma.workloadCredential.findUniqueOrThrow({ + where: { id: provisioned.credential.id }, + select: { revokedAt: true }, + }) + expect(revokedCredential.revokedAt).toBeInstanceOf(Date) + expect( + await prisma.securityAudit.findMany({ + where: { prototypeId: prototype.id, workloadPublisherId: provisioned.publisher.id }, + select: { + actorUserId: true, + event: true, + workloadCredentialId: true, + workloadPublisherId: true, + }, + }), + ).toEqual( + expect.arrayContaining([ + { + actorUserId: actor.user.id, + event: "prototype.publisher.create", + workloadCredentialId: null, + workloadPublisherId: provisioned.publisher.id, + }, + { + actorUserId: actor.user.id, + event: "prototype.publisher.disable", + workloadCredentialId: null, + workloadPublisherId: provisioned.publisher.id, + }, + { + actorUserId: actor.user.id, + event: "prototype.publisher.enable", + workloadCredentialId: null, + workloadPublisherId: provisioned.publisher.id, + }, + { + actorUserId: actor.user.id, + event: "prototype.publisher.credential.revoke", + workloadCredentialId: provisioned.credential.id, + workloadPublisherId: provisioned.publisher.id, + }, + ]), + ) + expect( + await prisma.securityAudit.count({ + where: { prototypeId: prototype.id, workloadPublisherId: provisioned.publisher.id }, + }), + ).toBe(4) + }) + + it("requires publish permission when provisioning a publisher", async () => { + const { prisma, service, store } = await database() + const token = "publisher-action-token" + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-action", + token, + }) + + await expect( + service.createPublisherCredential(token, prototype.id, { + name: "Status-only publisher", + actions: ["read-status"], + }), + ).rejects.toMatchObject({ status: 422, code: "invalid_workload_publisher" }) + expect(await prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } })).toBe(0) + }) + + it.each(["viewer", "editor"] as const)( + "rejects a fresh %s provisioning a publisher without writing publisher state or an audit", + async (role) => { + const { prisma, service, store } = await database() + const administratorToken = `publisher-${role}-administrator-token` + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: `publisher-${role}-administrator`, + token: administratorToken, + }) + const actor = await createActor(store, { + subject: `publisher-${role}`, + token: `publisher-${role}-token`, + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: prototype.id, + userId: actor.user.id, + effect: "allow", + role, + }, + }) + + await expect( + service.createPublisherCredential(`publisher-${role}-token`, prototype.id, { + name: `${role} publisher`, + actions: ["publish"], + }), + ).rejects.toMatchObject({ status: 403, code: "prototype_access_denied" }) + expect(await prisma.workloadPublisher.count({ where: { prototypeId: prototype.id } })).toBe(0) + expect(await prisma.workloadCredential.count()).toBe(0) + expect( + await prisma.securityAudit.count({ + where: { prototypeId: prototype.id, event: "prototype.publisher.create" }, + }), + ).toBe(0) + }, + ) + + it("lists bounded publisher and credential metadata without reusable secrets", async () => { + const { prisma, service, store } = await database() + const token = "publisher-list-token" + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-list", + token, + }) + const first = (await service.createPublisherCredential(token, prototype.id, { + name: "First publisher", + actions: ["publish"], + })) as { credential: { token: string } } + const second = (await service.createPublisherCredential(token, prototype.id, { + name: "Second publisher", + actions: ["publish", "read-status"], + })) as { credential: { token: string } } + + const firstPage = (await service.listPublishers(token, prototype.id, { + limit: 1, + })) as { + items: { id: string; credentials: { id: string }[] }[] + nextCursor: string | null + } + const secondPage = (await service.listPublishers(token, prototype.id, { + limit: 1, + cursor: firstPage.nextCursor, + })) as { + items: { id: string; credentials: { id: string }[] }[] + nextCursor: string | null + } + const serialized = JSON.stringify([firstPage, secondPage]) + const tokenDigests = await prisma.workloadCredential.findMany({ + select: { tokenDigest: true }, + }) + + expect(firstPage.items).toHaveLength(1) + expect(firstPage.nextCursor).toBe(firstPage.items[0]?.id) + expect(secondPage.items).toHaveLength(1) + expect(secondPage.nextCursor).toBeNull() + expect(new Set([...firstPage.items, ...secondPage.items].map(({ id }) => id)).size).toBe(2) + expect(serialized).not.toContain(first.credential.token) + expect(serialized).not.toContain(second.credential.token) + for (const { tokenDigest } of tokenDigests) expect(serialized).not.toContain(tokenDigest) + expect(serialized).not.toContain("tokenDigest") + }) + + it.each(["stale-administrator", "fresh-editor"] as const)( + "rejects publisher metadata listing for a %s", + async (access) => { + const { prisma, service, store } = await database() + const administratorToken = `publisher-list-${access}-administrator-token` + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: `publisher-list-${access}-administrator`, + token: administratorToken, + }) + const token = + access === "stale-administrator" ? administratorToken : `publisher-list-${access}-token` + if (access === "stale-administrator") { + await prisma.session.updateMany({ + where: { tokenDigest: digestSecret(token) }, + data: { freshAuthorizedAt: new Date(Date.now() - 5 * 60_000) }, + }) + } else { + const actor = await createActor(store, { + subject: `publisher-list-${access}`, + token, + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: prototype.id, + userId: actor.user.id, + effect: "allow", + role: "editor", + }, + }) + } + + await expect(service.listPublishers(token, prototype.id, {})).rejects.toMatchObject({ + status: 403, + code: + access === "stale-administrator" + ? "fresh_authorization_required" + : "prototype_access_denied", + }) + }, + ) + + it("lists publisher metadata without the identity-store or prototype write locks", async () => { + const { prisma, service, store } = await database() + const token = "publisher-list-lock-free-token" + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-list-lock-free", + token, + }) + const provisioned = (await service.createPublisherCredential(token, prototype.id, { + name: "Lock-free publisher", + actions: ["publish"], + })) as { publisher: { id: string } } + const identityStoreLock = vi.spyOn(store, "lock") + const prototypeLock = await holdPrototypeLock(prisma, prototype.id) + const listing = service.listPublishers(token, prototype.id, {}) + + const completedWhileLocked = await completesWithin(listing, 1_000) + prototypeLock.release() + await prototypeLock.holder + const page = (await listing) as { items: { id: string }[] } + + expect(completedWhileLocked).toBe(true) + expect(page.items).toEqual([expect.objectContaining({ id: provisioned.publisher.id })]) + expect(identityStoreLock).not.toHaveBeenCalled() + }) + + it("does not apply publisher or credential lifecycle operations across prototypes", async () => { + const { prisma, service, store } = await database() + const token = "publisher-scope-token" + const { prototype, workspace } = await createAdministeredPrototype({ + service, + store, + subject: "publisher-scope", + token, + }) + const otherPrototype = (await service.createPrototype(token, workspace.id, { + name: "Other prototype", + })) as { id: string } + const provisioned = (await service.createPublisherCredential(token, prototype.id, { + name: "Scoped publisher", + actions: ["publish"], + })) as { + publisher: { id: string } + credential: { id: string } + } + + await expect( + service.setPublisherActive(token, otherPrototype.id, provisioned.publisher.id, { + active: false, + }), + ).rejects.toMatchObject({ status: 404, code: "workload_publisher_not_found" }) + await expect( + service.revokePublisherCredential( + token, + otherPrototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ), + ).rejects.toMatchObject({ status: 404, code: "workload_credential_not_found" }) + + expect( + await prisma.workloadPublisher.findUniqueOrThrow({ + where: { id: provisioned.publisher.id }, + select: { active: true, credentials: { select: { revokedAt: true } } }, + }), + ).toEqual({ active: true, credentials: [{ revokedAt: null }] }) + expect( + await prisma.securityAudit.count({ + where: { + prototypeId: otherPrototype.id, + event: { startsWith: "prototype.publisher." }, + }, + }), + ).toBe(0) + }) + + it("allows disable and credential revocation after archive but blocks creation and re-enable", async () => { + const { prisma, service, store } = await database() + const token = "archived-publisher-token" + const { prototype } = await createAdministeredPrototype({ + service, + store, + subject: "archived-publisher", + token, + }) + const provisioned = (await service.createPublisherCredential(token, prototype.id, { + name: "Archived publisher", + actions: ["publish"], + })) as { + publisher: { id: string } + credential: { id: string } + } + await service.archivePrototype(token, prototype.id) + + await expect( + service.createPublisherCredential(token, prototype.id, { + name: "New archived publisher", + actions: ["publish"], + }), + ).rejects.toMatchObject({ status: 409, code: "prototype_archived" }) + await service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: false, + }) + await expect( + service.setPublisherActive(token, prototype.id, provisioned.publisher.id, { + active: true, + }), + ).rejects.toMatchObject({ status: 409, code: "prototype_archived" }) + await service.revokePublisherCredential( + token, + prototype.id, + provisioned.publisher.id, + provisioned.credential.id, + ) + + const publisher = await prisma.workloadPublisher.findUniqueOrThrow({ + where: { id: provisioned.publisher.id }, + select: { active: true, credentials: { select: { revokedAt: true } } }, + }) + expect(publisher.active).toBe(false) + expect(publisher.credentials).toHaveLength(1) + expect(publisher.credentials[0]?.revokedAt).toBeInstanceOf(Date) + expect( + ( + await prisma.securityAudit.findMany({ + where: { + prototypeId: prototype.id, + event: { + in: ["prototype.publisher.disable", "prototype.publisher.credential.revoke"], + }, + }, + select: { event: true }, + }) + ).sort((left, right) => left.event.localeCompare(right.event)), + ).toEqual([ + { event: "prototype.publisher.credential.revoke" }, + { event: "prototype.publisher.disable" }, + ]) + }) + + 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("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 baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const baseline: PrototypeBaselinePolicy = { + baseline: baselineAccess, + } + 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(baselineAccess.mock.calls).toContainEqual([prisma, actor.user.id, prototype.id]) + }) + + it("uses baseline authorization for single-prototype reads and role-gated mutations", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "administrator", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "admin", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + const actor = await createActor(store, { + subject: "baseline-single-prototype", + token: "baseline-single-prototype-token", + }) + 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: "github-synchronized", + }) + 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.createPublisherCredential("baseline-single-prototype-token", prototype.id, { + name: "Baseline publisher", + actions: ["publish"], + }), + ).resolves.toMatchObject({ publisher: { prototypeId: prototype.id } }) + 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(baselineAccess.mock.calls.length).toBeGreaterThanOrEqual(8) + expect( + baselineAccess.mock.calls.every( + ([database, userId, prototypeId]) => + database && 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 baselineAccess = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + 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(baselineAccess).not.toHaveBeenCalled() + }) + + it("lets a workspace deny override prototype and baseline access when listing", async () => { + const baselineAccess = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) + const baseline: PrototypeBaselinePolicy = { + baseline: baselineAccess, + } + 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(baselineAccess.mock.calls).toHaveLength(0) + }) + + it("uses an explicit prototype role without consulting a failing baseline", async () => { + const baselineAccess = vi + .fn() + .mockRejectedValue(new Error("baseline unavailable")) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + 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", + "authorizationMode", + "createdAt", + "createdByUserId", + "id", + "name", + "sourceRepositoryId", + "updatedAt", + "workspaceId", + ].sort(), + ) + expect(baselineAccess.mock.calls).toHaveLength(0) + }) + + it("makes synchronized memberships manual when granting workspace or prototype administration", async () => { + const { prisma, service, store } = await database() + const actor = await createActor(store, { + subject: "administration-actor", + token: "administration-actor-token", + role: "administrator", + }) + const workspaceTarget = await createActor(store, { + subject: "workspace-administrator-target", + token: "workspace-administrator-target-token", + source: "synchronized", + }) + const prototypeTarget = await createActor(store, { + subject: "prototype-administrator-target", + token: "prototype-administrator-target-token", + source: "synchronized", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + grants: { + create: { + userId: actor.user.id, + effect: "allow", + role: "administrator", + }, + }, + }, + }) + + await service.setWorkspaceAccess( + "administration-actor-token", + workspace.id, + workspaceTarget.user.id, + { effect: "allow", role: "administrator" }, + ) + await service.setPrototypeAccess( + "administration-actor-token", + prototype.id, + prototypeTarget.user.id, + { effect: "allow", role: "administrator" }, + ) + + expect( + await prisma.installationMembership.findUnique({ + where: { userId: workspaceTarget.user.id }, + }), + ).toMatchObject({ source: "manual", status: "active" }) + expect( + await prisma.installationMembership.findUnique({ + where: { userId: prototypeTarget.user.id }, + }), + ).toMatchObject({ source: "manual", status: "active" }) + expect( + await prisma.securityAudit.findMany({ + where: { event: "membership.synchronized.preserve" }, + orderBy: { createdAt: "asc" }, + }), + ).toEqual([ + expect.objectContaining({ + actorUserId: actor.user.id, + sessionId: actor.session.id, + authenticationProviderRevisionId: actor.session.authenticationProviderRevisionId, + targetMembershipId: workspaceTarget.membership.id, + targetUserId: workspaceTarget.user.id, + context: { reason: "product_administration" }, + }), + expect.objectContaining({ + actorUserId: actor.user.id, + sessionId: actor.session.id, + authenticationProviderRevisionId: actor.session.authenticationProviderRevisionId, + targetMembershipId: prototypeTarget.membership.id, + targetUserId: prototypeTarget.user.id, + context: { reason: "product_administration" }, + }), + ]) + }) + + it("passes the mutation transaction to a synchronized prototype baseline", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "administrator", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "write", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + const actor = await createActor(store, { + subject: "transaction-baseline", + token: "transaction-baseline-token", + }) + const workspace = await prisma.workspace.create({ + data: { name: "Workspace", createdByUserId: actor.user.id }, + }) + const prototype = await prisma.prototype.create({ + data: { + name: "Prototype", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + }) + + await expect( + service.createPublisherCredential("transaction-baseline-token", prototype.id, { + name: "Baseline publisher", + actions: ["publish"], + }), + ).resolves.toMatchObject({ publisher: { prototypeId: prototype.id } }) + + expect(baselineAccess).toHaveBeenCalledOnce() + expect(baselineAccess.mock.calls[0]?.slice(1)).toEqual([actor.user.id, prototype.id]) + expect(baselineAccess.mock.calls[0]?.[0]).not.toBe(prisma) + }) + + it("does not evaluate prototype baselines in unrelated workspaces while listing workspaces", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const baselineWorkspaceIds = vi.fn>() + const { prisma, service, store } = await database({ + baseline: baselineAccess, + workspaceIds: baselineWorkspaceIds, + }) + const actor = await createActor(store, { + subject: "workspace-baseline-scope", + token: "workspace-baseline-scope-token", + }) + const accessibleWorkspace = await prisma.workspace.create({ + data: { + name: "Accessible workspace", + createdByUserId: actor.user.id, + prototypes: { + create: { + name: "Accessible prototype", + createdByUserId: actor.user.id, + }, + }, + }, + include: { prototypes: true }, + }) + await Promise.all( + Array.from({ length: 5 }, (_, index) => + prisma.workspace.create({ + data: { + name: `Unrelated workspace ${index}`, + createdByUserId: actor.user.id, + prototypes: { + create: { + name: `Unrelated prototype ${index}`, + createdByUserId: actor.user.id, + }, + }, + }, + }), + ), + ) + baselineWorkspaceIds.mockResolvedValue([accessibleWorkspace.id]) + + await expect( + service.listWorkspaces("workspace-baseline-scope-token", {}), + ).resolves.toMatchObject({ + items: [{ id: accessibleWorkspace.id }], + nextCursor: null, + }) + expect(baselineWorkspaceIds).toHaveBeenCalledWith(prisma, actor.user.id) + expect(baselineAccess.mock.calls).toEqual([ + [prisma, actor.user.id, accessibleWorkspace.prototypes[0]!.id], + ]) + }) + + it("uses batched baseline prototype IDs without per-prototype authorization queries", async () => { + const baselineAccess = vi.fn().mockResolvedValue({ + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + }) + const baselinePrototypeIds = vi.fn>() + const { prisma, service, store } = await database({ + baseline: baselineAccess, + prototypeIds: baselinePrototypeIds, + }) + const actor = await createActor(store, { + subject: "prototype-baseline-batch", + token: "prototype-baseline-batch-token", + }) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: actor.user.id, + }, + }) + const directId = "00000000-0000-4000-8000-000000000010" + const baselineId = "00000000-0000-4000-8000-000000000020" + await prisma.prototype.createMany({ + data: [ + { + id: directId, + name: "Direct", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + { + id: baselineId, + name: "Synchronized", + workspaceId: workspace.id, + createdByUserId: actor.user.id, + }, + ...Array.from({ length: 5 }, (_, index) => ({ + id: `00000000-0000-4000-8000-00000000010${index}`, + name: `Unrelated ${index}`, + workspaceId: workspace.id, + createdByUserId: actor.user.id, + })), + ], + }) + await prisma.prototypeAccessGrant.create({ + data: { + prototypeId: directId, + userId: actor.user.id, + effect: "allow", + role: "viewer", + }, + }) + baselinePrototypeIds.mockResolvedValue([baselineId]) + const prototypePageQueries = vi.spyOn(prisma.prototype, "findMany") + const anyPrototypeQueries = vi.spyOn(prisma.prototype, "findFirst") + + await expect( + service.getWorkspace("prototype-baseline-batch-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.listPrototypes("prototype-baseline-batch-token", workspace.id, {}), + ).resolves.toMatchObject({ + items: [{ id: directId }, { id: baselineId }], + nextCursor: null, + }) + + expect(baselinePrototypeIds).toHaveBeenCalledTimes(3) + expect( + baselinePrototypeIds.mock.calls.every( + ([database, userId, workspaceId]) => + database === prisma && userId === actor.user.id && workspaceId === workspace.id, + ), + ).toBe(true) + expect(baselineAccess).toHaveBeenCalledOnce() + expect(baselineAccess).toHaveBeenCalledWith(prisma, actor.user.id, baselineId) + const anyQuery = JSON.stringify(anyPrototypeQueries.mock.calls[0]?.[0]) + expect(anyQuery).toContain(workspace.id) + expect(anyQuery).toContain(actor.user.id) + expect(anyQuery).toContain(baselineId) + expect(prototypePageQueries).toHaveBeenCalledOnce() + const pageQuery = JSON.stringify(prototypePageQueries.mock.calls[0]?.[0]) + expect(pageQuery).toContain(workspace.id) + expect(pageQuery).toContain(actor.user.id) + expect(pageQuery).toContain(baselineId) + }) + + it("gets a workspace through prototype or baseline access while an explicit workspace deny wins", async () => { + const baselineUsers = new Set() + const baselineAccess = vi + .fn() + .mockImplementation((_database, userId) => + Promise.resolve( + baselineUsers.has(userId) + ? { + role: "viewer", + repositorySourceId: "repository-source", + githubRepositoryId: "100", + rawPermission: "read", + confirmedAt: new Date(), + synchronizationStatus: "current", + } + : null, + ), + ) + const { prisma, service, store } = await database({ baseline: baselineAccess }) + const prototypeOnly = await createActor(store, { + subject: "workspace-prototype-only", + token: "workspace-prototype-only-token", + }) + const synchronized = await createActor(store, { + subject: "workspace-synchronized", + token: "workspace-synchronized-token", + }) + const denied = await createActor(store, { + subject: "workspace-denied", + token: "workspace-denied-token", + }) + baselineUsers.add(synchronized.user.id) + baselineUsers.add(denied.user.id) + const workspace = await prisma.workspace.create({ + data: { + name: "Workspace", + createdByUserId: prototypeOnly.user.id, + grants: { + create: { + userId: denied.user.id, + effect: "deny", + }, + }, + prototypes: { + create: { + name: "Prototype", + createdByUserId: prototypeOnly.user.id, + grants: { + create: { + userId: prototypeOnly.user.id, + effect: "allow", + role: "viewer", + }, + }, + }, + }, + }, + }) + + await expect( + service.getWorkspace("workspace-prototype-only-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.getWorkspace("workspace-synchronized-token", workspace.id), + ).resolves.toMatchObject({ id: workspace.id }) + await expect( + service.getWorkspace("workspace-denied-token", workspace.id), + ).rejects.toMatchObject({ + status: 403, + code: "prototype_access_denied", + }) + }) + + it("fills prototype pages after excluding explicit denies and continues without duplicates", async () => { + 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("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) + }) +}) 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..ad89a1d9 --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/index.ts @@ -0,0 +1,1364 @@ +import { randomBytes, randomUUID } from "node:crypto" + +import { z } from "zod" + +import { createIdentityRecords } from "#server/domains/identity/create-records" +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import { + 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 { digestSecret } from "#server/utils/digest-secret" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +const nameSchema = z.object({ name: z.string().trim().min(1).max(120) }) +const publisherSchema = z + .object({ + name: z.string().trim().min(1).max(120), + actions: z + .array(z.enum(["publish", "read-status"])) + .min(1) + .max(2) + .refine((actions) => new Set(actions).size === actions.length && actions.includes("publish")), + expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + }) + .strict() +const publisherStateSchema = z.object({ active: z.boolean() }).strict() +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() +const authorizationSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("hub-managed") }), + z.object({ mode: z.literal("github-synchronized"), repositorySourceId: z.uuid() }), +]) + +function authenticationDependencies(store: IdentityStore) { + return { + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed" as const, admit: () => Promise.resolve(null) }, + 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 preserveManualAdministration( + database: Prisma.TransactionClient, + actor: Awaited>, + userId: string, + change: GrantInput, +): Promise { + if (change.effect !== "allow" || change.role !== "administrator") return + const membership = await database.installationMembership.findUnique({ + where: { userId }, + select: { id: true, source: true, status: true }, + }) + if (membership?.source !== "synchronized" || membership.status !== "active") return + const changed = await database.installationMembership.updateMany({ + where: { id: membership.id, source: "synchronized", status: "active" }, + data: { source: "manual" }, + }) + if (changed.count === 0) return + await saveSecurityAudit(database, { + event: "membership.synchronized.preserve", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + targetMembershipId: membership.id, + targetUserId: userId, + context: { reason: "product_administration" }, + }) +} + +async function lockWorkspace( + database: Prisma.TransactionClient, + workspaceId: string, +): 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 lockGitHubRepositorySource( + database: Prisma.TransactionClient, + repositorySourceId: string, +): Promise { + await database.$queryRaw` + SELECT "id" + FROM "github_repository_sources" + WHERE "id" = ${repositorySourceId}::uuid + FOR UPDATE + ` +} + +async function lockWorkspacePrototypes( + database: Prisma.TransactionClient, + workspaceId: string, +): 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.") + } + const decision = await prototypeAccessDecision( + baseline, + database, + userId, + prototype.workspace.grants[0] ?? null, + prototype, + ) + return { + prototype, + decision, + } +} + +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 + authorizationMode: string + sourceRepositoryId: string | null + archivedAt: Date | null + createdAt: Date + updatedAt: Date +}) { + return { + id: prototype.id, + workspaceId: prototype.workspaceId, + createdByUserId: prototype.createdByUserId, + name: prototype.name, + authorizationMode: prototype.authorizationMode, + sourceRepositoryId: prototype.sourceRepositoryId, + archivedAt: prototype.archivedAt, + createdAt: prototype.createdAt, + updatedAt: prototype.updatedAt, + } +} + +async function prototypeAccessDecision( + baseline: PrototypeBaselinePolicy | null, + database: Prisma.TransactionClient | PrismaClient, + 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({ + baseline: await baseline.baseline(database, userId, prototype.id), + }) +} + +async function readAccessibleWorkspacePage({ + baseline, + database, + userId, + cursor, + limit, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + cursor?: string + limit: number +}) { + const baselineWorkspaceIds = baseline?.workspaceIds + ? await baseline.workspaceIds(database, userId) + : [] + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.workspace.findMany({ + where: { + ...(scanCursor ? { id: { gt: scanCursor } } : {}), + OR: [ + { grants: { some: { userId } } }, + { prototypes: { some: { grants: { some: { userId } } } } }, + ...(baselineWorkspaceIds.length > 0 ? [{ id: { in: baselineWorkspaceIds } }] : []), + ], + }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + const workspaceGrant = candidate.grants[0] ?? null + if ( + evaluatePrototypeAccess({ workspaceGrant }).allowed || + (await hasAnyPrototypeAccess({ + baseline, + database, + userId, + workspaceId: candidate.id, + workspaceGrant, + })) + ) { + items.push({ + id: candidate.id, + createdByUserId: candidate.createdByUserId, + name: candidate.name, + createdAt: candidate.createdAt, + updatedAt: candidate.updatedAt, + }) + } + if (items.length > limit) break + } + if (candidates.length < 100) break + } + return pageResult(items, limit) +} + +async function readAccessiblePrototypePage({ + baseline, + database, + userId, + workspaceId, + workspaceGrant, + cursor, + limit, +}: { + baseline: PrototypeBaselinePolicy | null + database: PrismaClient + userId: string + workspaceId: string + workspaceGrant: GrantInput | null + cursor?: string + limit: number +}) { + if (workspaceGrant?.effect === "allow" || !baseline) { + const accessWhere = + workspaceGrant?.effect === "allow" + ? { grants: { none: { userId, effect: "deny" as const } } } + : { grants: { some: { userId, effect: "allow" as const } } } + const items = await database.prototype.findMany({ + where: { + workspaceId, + ...(cursor ? { id: { gt: cursor } } : {}), + ...accessWhere, + }, + orderBy: { id: "asc" }, + take: limit + 1, + }) + return pageResult(items.map(prototypeDto), limit) + } + const baselinePrototypeIds = baseline.prototypeIds + ? await baseline.prototypeIds(database, userId, workspaceId) + : [] + if (!baseline.prototypeIds) { + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.prototype.findMany({ + where: { workspaceId, ...(scanCursor ? { id: { gt: scanCursor } } : {}) }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + if (candidates.length === 0) break + for (const candidate of candidates) { + scanCursor = candidate.id + if ( + (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) + .allowed + ) { + items.push(prototypeDto(candidate)) + } + if (items.length > limit) break + } + if (candidates.length < 100) break + } + return pageResult(items, limit) + } + const items = [] + let scanCursor = cursor + while (items.length <= limit) { + const candidates = await database.prototype.findMany({ + where: { + workspaceId, + ...(scanCursor ? { id: { gt: scanCursor } } : {}), + OR: [ + { grants: { some: { userId, effect: "allow" } } }, + ...(baselinePrototypeIds.length > 0 ? [{ id: { in: baselinePrototypeIds } }] : []), + ], + }, + 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, + database, + 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 + const baselinePrototypeIds = baseline?.prototypeIds + ? await baseline.prototypeIds(database, userId, workspaceId) + : [] + if (baseline && !baseline.prototypeIds) { + let cursor: string | undefined + while (true) { + const candidates = await database.prototype.findMany({ + where: { workspaceId, ...(cursor ? { id: { gt: cursor } } : {}) }, + include: { grants: { where: { userId } } }, + orderBy: { id: "asc" }, + take: 100, + }) + for (const candidate of candidates) { + if ( + (await prototypeAccessDecision(baseline, database, userId, workspaceGrant, candidate)) + .allowed + ) { + return true + } + } + if (candidates.length < 100) return false + cursor = candidates.at(-1)?.id + } + } + return Boolean( + await database.prototype.findFirst({ + where: { + workspaceId, + OR: [ + { grants: { some: { userId, effect: "allow" } } }, + ...(baselinePrototypeIds.length > 0 ? [{ id: { in: baselinePrototypeIds } }] : []), + ], + }, + select: { id: true }, + }), + ) +} + +function versionResult(version: { + id: string + prototypeId: string + sequence: number + label: string | null + artifactFormat: string | null + artifactVersion: string | null + contentDigest: string | null + sizeBytes: number | null + builtAt: Date | null + provenanceProvider: string | null + provenanceSourceUrl: string | null + provenanceRevision: string | null + provenanceRef: string | null + provenancePipeline: string | null + provenanceRunId: string | null + createdAt: Date +}) { + return { + id: version.id, + prototypeId: version.prototypeId, + sequence: version.sequence, + label: version.label, + artifact: version.contentDigest + ? { + format: version.artifactFormat, + version: version.artifactVersion, + digest: version.contentDigest, + sizeBytes: version.sizeBytes, + builtAt: version.builtAt, + } + : null, + provenance: version.provenanceProvider + ? { + provider: version.provenanceProvider, + sourceUrl: version.provenanceSourceUrl, + revision: version.provenanceRevision, + ref: version.provenanceRef, + pipeline: version.provenancePipeline, + runId: version.provenanceRunId, + } + : null, + createdAt: version.createdAt, + } +} + +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) + return readAccessibleWorkspacePage({ + baseline, + database, + userId: actor.userId, + cursor, + 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) + if ( + !decision.allowed && + !(await hasAnyPrototypeAccess({ + baseline, + database, + userId: actor.userId, + workspaceId, + workspaceGrant: workspace.grants[0] ?? null, + })) + ) { + requireDecision(decision, "viewer") + } + return { + id: workspace.id, + createdByUserId: workspace.createdByUserId, + 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 preserveManualAdministration(transaction, actor, targetId, 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) + await requirePrototypeCursor(database, workspaceId, cursor) + const workspaceGrant = workspace.grants[0] ?? null + if ( + !(await hasAnyPrototypeAccess({ + baseline, + database, + userId: actor.userId, + workspaceId, + workspaceGrant, + })) + ) { + throw new AuthenticationError(403, "prototype_access_denied", "Prototype access is denied.") + } + return readAccessiblePrototypePage({ + baseline, + database, + userId: actor.userId, + workspaceId, + 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, + authorizationMode: prototype.authorizationMode, + sourceRepositoryId: prototype.sourceRepositoryId, + archivedAt: prototype.archivedAt, + createdAt: prototype.createdAt, + updatedAt: prototype.updatedAt, + } + }, + + async createPublisherCredential(sessionToken, prototypeId, input) { + const parsed = publisherSchema.safeParse(input) + if ( + !parsed.success || + (parsed.data.expiresAt !== null && + parsed.data.expiresAt !== undefined && + new Date(parsed.data.expiresAt) <= new Date()) + ) { + throw new AuthenticationError( + 422, + "invalid_workload_publisher", + "The workload publisher is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "administrator") + if (prototype.archivedAt) { + throw new AuthenticationError( + 409, + "prototype_archived", + "Archived prototypes cannot receive publishers.", + ) + } + const credentialId = randomUUID() + const token = `rpl_pub_${credentialId}.${randomBytes(32).toString("base64url")}` + const publisher = await transaction.workloadPublisher.create({ + data: { + prototypeId, + name: parsed.data.name, + actions: parsed.data.actions, + credentials: { + create: { + id: credentialId, + tokenDigest: digestSecret(token), + expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : null, + }, + }, + }, + }) + await audit(transaction, actor, { + event: "prototype.publisher.create", + workspaceId: prototype.workspaceId, + prototypeId, + workloadPublisherId: publisher.id, + context: { source: "prototype_administrator" }, + }) + return { + publisher: { + id: publisher.id, + prototypeId: publisher.prototypeId, + name: publisher.name, + actions: publisher.actions, + active: publisher.active, + createdAt: publisher.createdAt, + }, + credential: { id: credentialId, token }, + } + }) + }, + + async listPublishers(sessionToken, prototypeId, query) { + const { cursor, limit } = parsePage(query) + const actor = await currentSession(store, sessionToken) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + const { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) + requireDecision(decision, "administrator") + const items = await database.workloadPublisher.findMany({ + where: { prototypeId }, + orderBy: { id: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + select: { + id: true, + name: true, + actions: true, + active: true, + createdAt: true, + updatedAt: true, + credentials: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + expiresAt: true, + revokedAt: true, + createdAt: true, + }, + }, + }, + }) + return pageResult(items, limit) + }, + + async setPublisherActive(sessionToken, prototypeId, publisherId, input) { + const parsed = publisherStateSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_workload_publisher", + "The workload publisher is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + requireIdentifier( + publisherId, + "workload_publisher_not_found", + "The workload publisher was not found.", + ) + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "administrator") + if (prototype.archivedAt && parsed.data.active) { + throw new AuthenticationError( + 409, + "prototype_archived", + "Archived prototypes cannot enable publishers.", + ) + } + const publisher = await transaction.workloadPublisher.findFirst({ + where: { id: publisherId, prototypeId }, + }) + if (!publisher) { + throw new AuthenticationError( + 404, + "workload_publisher_not_found", + "The workload publisher was not found.", + ) + } + if (publisher.active === parsed.data.active) return publisher + const updated = await transaction.workloadPublisher.update({ + where: { id: publisher.id }, + data: { active: parsed.data.active }, + }) + await audit(transaction, actor, { + event: parsed.data.active ? "prototype.publisher.enable" : "prototype.publisher.disable", + workspaceId: prototype.workspaceId, + prototypeId, + workloadPublisherId: publisher.id, + context: { source: "prototype_administrator" }, + }) + return updated + }) + }, + + async revokePublisherCredential(sessionToken, prototypeId, publisherId, credentialId) { + 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.") + requireIdentifier( + publisherId, + "workload_publisher_not_found", + "The workload publisher was not found.", + ) + requireIdentifier( + credentialId, + "workload_credential_not_found", + "The workload credential was not found.", + ) + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "administrator") + const credential = await transaction.workloadCredential.findFirst({ + where: { + id: credentialId, + workloadPublisherId: publisherId, + workloadPublisher: { prototypeId }, + }, + }) + if (!credential) { + throw new AuthenticationError( + 404, + "workload_credential_not_found", + "The workload credential was not found.", + ) + } + if (credential.revokedAt) return + await transaction.workloadCredential.update({ + where: { id: credential.id }, + data: { revokedAt: new Date() }, + }) + await audit(transaction, actor, { + event: "prototype.publisher.credential.revoke", + workspaceId: prototype.workspaceId, + prototypeId, + workloadPublisherId: publisherId, + workloadCredentialId: credential.id, + context: { source: "prototype_administrator" }, + }) + }) + }, + + 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 setPrototypeAuthorization(sessionToken, prototypeId, input) { + const parsed = authorizationSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_authorization_policy", + "The prototype authorization policy is invalid.", + ) + } + return store.lock(async (_records, transaction) => { + const actor = await currentSession(store, sessionToken, transaction) + requireFreshAuthorization(actor) + requireIdentifier(prototypeId, "prototype_not_found", "The prototype was not found.") + await lockPrototype(transaction, prototypeId) + const { prototype, decision } = await prototypeDecision( + baseline, + transaction, + actor.userId, + prototypeId, + ) + requireDecision(decision, "administrator") + if (parsed.data.mode === "github-synchronized") { + await lockGitHubRepositorySource(transaction, parsed.data.repositorySourceId) + const source = await transaction.gitHubRepositorySource.findUnique({ + where: { id: parsed.data.repositorySourceId }, + }) + if (!source || source.status !== "active") { + throw new AuthenticationError( + 422, + "repository_source_unavailable", + "The linked GitHub repository is unavailable.", + ) + } + } + const updated = await transaction.prototype.update({ + where: { id: prototypeId }, + data: + parsed.data.mode === "hub-managed" + ? { authorizationMode: "hub-managed", sourceRepositoryId: null } + : { + authorizationMode: "github-synchronized", + sourceRepositoryId: parsed.data.repositorySourceId, + }, + }) + await audit(transaction, actor, { + event: "prototype.authorization.policy", + workspaceId: prototype.workspaceId, + prototypeId, + githubRepositorySourceId: + parsed.data.mode === "github-synchronized" ? parsed.data.repositorySourceId : null, + context: { + reason: parsed.data.mode === "hub-managed" ? "hub_managed" : "github_synchronized", + }, + }) + return updated + }) + }, + + async setPrototypeAccess(sessionToken, prototypeId, targetUserId, input) { + const change = parseGrant(input) + const targetId = normalizeIdentifier( + 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 preserveManualAdministration(transaction, actor, targetId, 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 { decision } = await prototypeDecision(baseline, database, actor.userId, prototypeId) + requireDecision(decision, "viewer") + await requireVersionCursor(database, prototypeId, cursor) + const items = await database.prototypeVersion.findMany({ + where: { prototypeId }, + orderBy: { sequence: "asc" }, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + take: limit + 1, + }) + const page = pageResult(items, limit) + return { ...page, items: page.items.map(versionResult) } + }, + + 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 versionResult(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..e578434d --- /dev/null +++ b/hub/server/domains/prototype-authorization/create-service/types.ts @@ -0,0 +1,87 @@ +import type { AccessDecision, AccessInput, PrototypeRole } from "../evaluate-access/types.ts" +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.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 + createPublisherCredential( + sessionToken: string, + prototypeId: string, + input: unknown, + ): Promise + listPublishers(sessionToken: string, prototypeId: string, query: unknown): Promise> + setPublisherActive( + sessionToken: string, + prototypeId: string, + publisherId: string, + input: unknown, + ): Promise + revokePublisherCredential( + sessionToken: string, + prototypeId: string, + publisherId: string, + credentialId: string, + ): Promise + archivePrototype(sessionToken: string, prototypeId: string): Promise + setPrototypeAuthorization( + sessionToken: string, + prototypeId: string, + input: unknown, + ): Promise + explainPrototypeAccess(sessionToken: string, prototypeId: string): Promise + setPrototypeAccess( + sessionToken: string, + prototypeId: string, + targetUserId: string, + input: unknown, + ): Promise + removePrototypeAccess( + sessionToken: string, + prototypeId: string, + targetUserId: string, + ): Promise + listVersions(sessionToken: string, prototypeId: string, query: unknown): Promise> + getVersion(sessionToken: string, prototypeId: string, versionId: string): Promise +} + +export interface GrantInput { + effect: "allow" | "deny" + role: PrototypeRole | null +} + +export interface PrototypeBaselinePolicy { + baseline( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + prototypeId: string, + ): Promise + workspaceIds?( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + ): Promise + prototypeIds?( + database: Prisma.TransactionClient | PrismaClient, + userId: string, + workspaceId: string, + ): Promise +} diff --git a/hub/server/domains/prototype-authorization/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..96a73a58 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest" + +import { evaluatePrototypeAccess } from "#server/domains/prototype-authorization/evaluate-access" + +const baseline = { + role: "editor" as const, + repositorySourceId: "repository-source", + githubRepositoryId: "123", + rawPermission: "write", + confirmedAt: new Date("2026-10-27T20:00:00.000Z"), + synchronizationStatus: "current", +} + +describe("evaluatePrototypeAccess()", () => { + it("uses a prototype role even when it restricts a stronger workspace role", () => { + expect( + evaluatePrototypeAccess({ + workspaceGrant: { effect: "allow", role: "administrator" }, + prototypeGrant: { effect: "allow", role: "viewer" }, + baseline, + }), + ).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, + baseline: { ...baseline, role: "administrator" }, + }), + ).toEqual({ + allowed: false, + role: null, + source: input.source, + reason: "explicit_deny", + }) + }) + + it("orders a prototype role before a workspace role before the GitHub baseline", () => { + expect( + evaluatePrototypeAccess({ + prototypeGrant: { effect: "allow", role: "viewer" }, + workspaceGrant: { effect: "allow", role: "commenter" }, + baseline, + }), + ).toEqual({ + allowed: true, + role: "viewer", + source: "hub-prototype", + reason: "prototype_role", + }) + + expect( + evaluatePrototypeAccess({ + workspaceGrant: { effect: "allow", role: "commenter" }, + baseline: { ...baseline, role: "administrator" }, + }), + ).toEqual({ + allowed: true, + role: "commenter", + source: "hub-workspace", + reason: "workspace_role", + }) + + expect(evaluatePrototypeAccess({ baseline })).toEqual({ + allowed: true, + role: "editor", + source: "github-synchronized", + reason: "github_baseline", + baseline: { + repositorySourceId: "repository-source", + githubRepositoryId: "123", + rawPermission: "write", + confirmedAt: new Date("2026-10-27T20:00:00.000Z"), + synchronizationStatus: "current", + }, + }) + }) + + 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..d4d9eac7 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/index.ts @@ -0,0 +1,56 @@ +import type { + AccessDecision, + AccessInput, +} from "#server/domains/prototype-authorization/evaluate-access/types" + +export function evaluatePrototypeAccess({ + workspaceGrant, + prototypeGrant, + baseline = 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 (baseline) { + return { + allowed: true, + role: baseline.role, + source: "github-synchronized", + reason: "github_baseline", + baseline: { + repositorySourceId: baseline.repositorySourceId, + githubRepositoryId: baseline.githubRepositoryId, + rawPermission: baseline.rawPermission, + confirmedAt: baseline.confirmedAt, + synchronizationStatus: baseline.synchronizationStatus, + }, + } + } + return { + 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..1e760fa4 --- /dev/null +++ b/hub/server/domains/prototype-authorization/evaluate-access/types.ts @@ -0,0 +1,33 @@ +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" | "github-synchronized" | "none" + reason: "explicit_deny" | "prototype_role" | "workspace_role" | "github_baseline" | "no_access" + baseline?: { + repositorySourceId: string + githubRepositoryId: string + rawPermission: string + confirmedAt: Date + synchronizationStatus: string + } +} + +export interface AccessInput { + workspaceGrant?: AccessGrant | null + prototypeGrant?: AccessGrant | null + baseline?: { + role: PrototypeRole + repositorySourceId: string + githubRepositoryId: string + rawPermission: string + confirmedAt: Date + synchronizationStatus: string + } | null +} diff --git a/hub/server/index.ts b/hub/server/index.ts index 64bafe13..ecf78b6b 100644 --- a/hub/server/index.ts +++ b/hub/server/index.ts @@ -1,6 +1,7 @@ import "dotenv/config" import { createApp } from "./create-app/index.ts" +import { createAuthenticationRuntime } from "./domains/authentication/create-runtime/index.ts" import { log } from "./integrations/pino/log/index.ts" const port = Number(process.env.PORT ?? 3001) @@ -8,6 +9,16 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("PORT must be an integer between 1 and 65535.") } -createApp().listen(port, "127.0.0.1", () => { +const authentication = await createAuthenticationRuntime(process.env) +const reconciliationInterval = setInterval(() => { + void authentication.githubAuthorization.reconcileDue().catch(() => { + log("error", "GitHub authorization reconciliation failed", { + reason: "reconciliation_failed", + }) + }) +}, 15 * 60_000) +reconciliationInterval.unref() + +createApp({ authentication }).listen(port, "127.0.0.1", () => { log("info", "Hub API listening", { host: "127.0.0.1", port }) }) diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts new file mode 100644 index 00000000..1b369a50 --- /dev/null +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.test.ts @@ -0,0 +1,354 @@ +import { createHash, randomUUID } from "node:crypto" +import { mkdir, open, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises" +import path from "node:path" + +import { describe, expect, it, onTestFinished, vi } from "vitest" + +import { createLocalArtifactStorage } from "#server/integrations/artifact-storage/create-local-storage" + +function storageRoot() { + const root = path.join(process.cwd(), `.artifact-storage-test-${randomUUID()}`) + onTestFinished(() => rm(root, { recursive: true, force: true })) + return root +} + +function digest(bytes: Uint8Array) { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}` +} + +function artifactPath(root: string, contentDigest: string) { + const hash = contentDigest.slice("sha256:".length) + return { + algorithm: path.join(root, "sha256"), + shard: path.join(root, "sha256", hash.slice(0, 2)), + artifact: path.join(root, "sha256", hash.slice(0, 2), hash), + key: `sha256/${hash.slice(0, 2)}/${hash}`, + } +} + +function bytesInShard(shard: string) { + for (let index = 0; ; index += 1) { + const bytes = Buffer.from(`outside write probe ${index}`) + if (digest(bytes).slice("sha256:".length, "sha256:".length + 2) === shard) return bytes + } +} + +async function directoryIdentity(directory: string) { + const status = await stat(directory) + return `${status.dev}:${status.ino}` +} + +async function expectSymlinkRejected({ + root, + key, + contentDigest, + bytes, + sentinel, + writeProbe, +}: { + root: string + key: string + contentDigest: string + bytes: Uint8Array + sentinel: string + writeProbe?: { bytes: Uint8Array; outsidePath: string } +}) { + const storage = createLocalArtifactStorage(root) + + await expect(storage.read(key)).rejects.toThrow( + "Artifact storage paths cannot contain symbolic links.", + ) + await expect(storage.put(contentDigest, bytes)).rejects.toThrow( + "Artifact storage paths cannot contain symbolic links.", + ) + if (writeProbe) { + await expect(storage.put(digest(writeProbe.bytes), writeProbe.bytes)).rejects.toThrow( + "Artifact storage paths cannot contain symbolic links.", + ) + await expect(readFile(writeProbe.outsidePath)).rejects.toMatchObject({ code: "ENOENT" }) + } + await expect(storage.remove(key)).rejects.toThrow( + "Artifact storage paths cannot contain symbolic links.", + ) + expect(await readFile(sentinel)).toEqual(bytes) +} + +describe("createLocalArtifactStorage()", () => { + it("requires an absolute storage root", () => { + expect(() => createLocalArtifactStorage("relative/artifacts")).toThrow( + "HUB_ARTIFACT_STORAGE_PATH must be an absolute path.", + ) + }) + + it("stores and reads artifacts by a deterministic content-addressed key", async () => { + const storage = createLocalArtifactStorage(storageRoot()) + const bytes = Buffer.from("content-addressed artifact") + const contentDigest = digest(bytes) + + const stored = await storage.put(contentDigest, bytes) + + expect(stored).toEqual({ + key: `sha256/${contentDigest.slice(7, 9)}/${contentDigest.slice(7)}`, + created: true, + }) + expect(Buffer.from(await storage.read(stored.key))).toEqual(bytes) + }) + + it("rejects mismatched bytes without overwriting existing content", async () => { + const storage = createLocalArtifactStorage(storageRoot()) + const original = Buffer.from("immutable artifact") + const contentDigest = digest(original) + + const first = await storage.put(contentDigest, original) + await expect(storage.put(contentDigest, Buffer.from("different bytes"))).rejects.toThrow( + "Artifact bytes do not match the content digest.", + ) + + expect(Buffer.from(await storage.read(first.key))).toEqual(original) + }) + + it("converges concurrent writes for one digest on a single stored object", async () => { + const storage = createLocalArtifactStorage(storageRoot()) + const bytes = Buffer.from("concurrent artifact") + const contentDigest = digest(bytes) + + const results = await Promise.all([ + storage.put(contentDigest, bytes), + storage.put(contentDigest, bytes), + ]) + + expect(new Set(results.map(({ key }) => key))).toEqual(new Set([results[0].key])) + expect(results.filter(({ created }) => created)).toHaveLength(1) + expect(Buffer.from(await storage.read(results[0].key))).toEqual(bytes) + }) + + it("syncs the destination directory chain before new and deduplicated puts return", async () => { + const root = storageRoot() + const storage = createLocalArtifactStorage(root) + const bytes = Buffer.from("durable artifact") + const contentDigest = digest(bytes) + const location = artifactPath(root, contentDigest) + const probePath = path.join(process.cwd(), `.artifact-sync-probe-${randomUUID()}`) + const probe = await open(probePath, "w") + onTestFinished(async () => { + await probe.close() + await rm(probePath, { force: true }) + }) + const fileHandlePrototype = Object.getPrototypeOf(probe) as typeof probe + const syncedDirectories: string[] = [] + vi.spyOn(fileHandlePrototype, "sync").mockImplementation(async function (this: typeof probe) { + const status = await this.stat() + if (status.isDirectory()) syncedDirectories.push(`${status.dev}:${status.ino}`) + }) + + await expect(storage.put(contentDigest, bytes)).resolves.toMatchObject({ created: true }) + const expectedDirectories = await Promise.all([ + directoryIdentity(location.shard), + directoryIdentity(location.algorithm), + directoryIdentity(root), + ]) + const outsideRoot = await directoryIdentity(path.dirname(root)) + expect(syncedDirectories).toEqual(expectedDirectories) + expect(syncedDirectories).not.toContain(outsideRoot) + + await expect(storage.put(contentDigest, bytes)).resolves.toMatchObject({ created: false }) + expect(syncedDirectories).toEqual([...expectedDirectories, ...expectedDirectories]) + expect(syncedDirectories).not.toContain(outsideRoot) + }) + + it("syncs the destination directory chain after a concurrent EEXIST dedupe", async () => { + const root = storageRoot() + const storage = createLocalArtifactStorage(root) + const bytes = Buffer.from("concurrent durable artifact") + const contentDigest = digest(bytes) + const location = artifactPath(root, contentDigest) + const probePath = path.join(process.cwd(), `.artifact-sync-probe-${randomUUID()}`) + const probe = await open(probePath, "w") + onTestFinished(async () => { + await probe.close() + await rm(probePath, { force: true }) + }) + const fileHandlePrototype = Object.getPrototypeOf(probe) as typeof probe + let regularFileSyncs = 0 + const syncedDirectories: string[] = [] + let releaseRegularFileSyncs!: () => void + const bothTemporaryFilesSynced = new Promise((resolve) => { + releaseRegularFileSyncs = resolve + }) + vi.spyOn(fileHandlePrototype, "sync").mockImplementation(async function (this: typeof probe) { + const status = await this.stat() + if (status.isDirectory()) { + syncedDirectories.push(`${status.dev}:${status.ino}`) + } else { + regularFileSyncs += 1 + if (regularFileSyncs === 2) releaseRegularFileSyncs() + await bothTemporaryFilesSynced + } + }) + + const results = await Promise.all([ + storage.put(contentDigest, bytes), + storage.put(contentDigest, bytes), + ]) + + expect(results.filter(({ created }) => created)).toHaveLength(1) + expect(results.filter(({ created }) => !created)).toHaveLength(1) + const expectedDirectories = await Promise.all([ + directoryIdentity(location.shard), + directoryIdentity(location.algorithm), + directoryIdentity(root), + ]) + expect(syncedDirectories.toSorted()).toEqual( + [...expectedDirectories, ...expectedDirectories].toSorted(), + ) + expect(syncedDirectories).not.toContain(await directoryIdentity(path.dirname(root))) + }) + + it.each(["writeFile", "sync"] as const)( + "removes its temporary file when %s fails", + async (operation) => { + const root = storageRoot() + const bytes = Buffer.from(`failed artifact ${operation}`) + const contentDigest = digest(bytes) + const location = artifactPath(root, contentDigest) + await mkdir(location.shard, { recursive: true }) + const probe = await open(path.join(root, "probe"), "w") + await probe.close() + const failure = vi + .spyOn(Object.getPrototypeOf(probe) as typeof probe, operation) + .mockRejectedValue(new Error(`${operation} failed`)) + + await expect(createLocalArtifactStorage(root).put(contentDigest, bytes)).rejects.toThrow( + `${operation} failed`, + ) + + expect((await readdir(location.shard)).filter((name) => name.endsWith(".tmp"))).toEqual([]) + failure.mockRestore() + }, + ) + + it("waits for a failed read before closing the artifact file", async () => { + const root = storageRoot() + const storage = createLocalArtifactStorage(root) + const bytes = Buffer.from("failed artifact read") + const stored = await storage.put(digest(bytes), bytes) + const probe = await open(path.join(root, "probe"), "w") + const fileHandlePrototype = Object.getPrototypeOf(probe) as typeof probe + await probe.close() + let rejectRead!: (error: Error) => void + let artifactFile: typeof probe | undefined + function captureArtifactFile(file: typeof probe) { + artifactFile = file + } + const readFailure = new Promise((_resolve, reject) => { + rejectRead = reject + }) + const readFile = vi.spyOn(fileHandlePrototype, "readFile").mockImplementation(function ( + this: typeof probe, + ) { + captureArtifactFile(this) + return readFailure + }) + + const read = storage.read(stored.key) + await vi.waitFor(() => expect(artifactFile).toBeDefined()) + await expect(artifactFile!.stat()).resolves.toBeDefined() + rejectRead(new Error("read failed")) + + await expect(read).rejects.toThrow("read failed") + await expect(artifactFile!.stat()).rejects.toMatchObject({ code: "EBADF" }) + readFile.mockRestore() + }) + + it.each([ + { name: "directly", suffix: "" }, + { name: "with a trailing separator", suffix: path.sep }, + { name: "with a trailing current-directory segment", suffix: `${path.sep}.` }, + ])( + "rejects a symbolic-link storage root $name without accessing outside content", + async ({ suffix }) => { + const root = storageRoot() + const outside = storageRoot() + const bytes = Buffer.from("outside root sentinel") + const contentDigest = digest(bytes) + const location = artifactPath(outside, contentDigest) + await mkdir(location.shard, { recursive: true }) + await writeFile(location.artifact, bytes) + await symlink(outside, root, "dir") + const writeBytes = Buffer.from("outside write probe") + + await expectSymlinkRejected({ + root: `${root}${suffix}`, + key: location.key, + contentDigest, + bytes, + sentinel: location.artifact, + writeProbe: { + bytes: writeBytes, + outsidePath: artifactPath(outside, digest(writeBytes)).artifact, + }, + }) + }, + ) + + it.each(["sha256", "shard"] as const)( + "rejects a symbolic-link %s directory without reading, replaying, or removing outside content", + async (level) => { + const root = storageRoot() + const outside = storageRoot() + const bytes = Buffer.from(`outside ${level} sentinel`) + const contentDigest = digest(bytes) + const inside = artifactPath(root, contentDigest) + const outsideLocation = artifactPath(outside, contentDigest) + const writeBytes = + level === "shard" + ? bytesInShard(contentDigest.slice("sha256:".length, "sha256:".length + 2)) + : Buffer.from("outside write probe") + + if (level === "sha256") { + await mkdir(outsideLocation.shard, { recursive: true }) + await writeFile(outsideLocation.artifact, bytes) + await mkdir(root, { recursive: true }) + await symlink(outsideLocation.algorithm, inside.algorithm, "dir") + } else { + await mkdir(inside.algorithm, { recursive: true }) + await mkdir(outsideLocation.shard, { recursive: true }) + await writeFile(outsideLocation.artifact, bytes) + await symlink(outsideLocation.shard, inside.shard, "dir") + } + + await expectSymlinkRejected({ + root, + key: inside.key, + contentDigest, + bytes, + sentinel: outsideLocation.artifact, + writeProbe: { + bytes: writeBytes, + outsidePath: artifactPath(outside, digest(writeBytes)).artifact, + }, + }) + }, + ) + + it("rejects a symbolic-link artifact without reading, replaying, or removing its outside target", async () => { + const root = storageRoot() + const outside = storageRoot() + const bytes = Buffer.from("outside artifact sentinel") + const contentDigest = digest(bytes) + const location = artifactPath(root, contentDigest) + const sentinel = path.join(outside, "sentinel") + await mkdir(location.shard, { recursive: true }) + await mkdir(outside, { recursive: true }) + await writeFile(sentinel, bytes) + await symlink(sentinel, location.artifact) + + await expectSymlinkRejected({ + root, + key: location.key, + contentDigest, + bytes, + sentinel, + }) + }) +}) diff --git a/hub/server/integrations/artifact-storage/create-local-storage/index.ts b/hub/server/integrations/artifact-storage/create-local-storage/index.ts new file mode 100644 index 00000000..d10933c3 --- /dev/null +++ b/hub/server/integrations/artifact-storage/create-local-storage/index.ts @@ -0,0 +1,221 @@ +import { createHash, randomUUID, timingSafeEqual } from "node:crypto" +import { constants } from "node:fs" +import { link, lstat, mkdir, open, realpath, rm, unlink } from "node:fs/promises" +import path from "node:path" + +import type { ArtifactStorage, StoredArtifact } from "#server/integrations/artifact-storage/types" + +function isFileSystemError(error: unknown, code: string): boolean { + return ( + error instanceof Error && + "code" in error && + typeof error.code === "string" && + error.code === code + ) +} + +function rejectSymbolicLink(): never { + throw new Error("Artifact storage paths cannot contain symbolic links.") +} + +function artifactKey(contentDigest: string): string { + if (!/^sha256:[a-f0-9]{64}$/.test(contentDigest)) { + throw new Error("Artifact content digest is invalid.") + } + const digest = contentDigest.slice("sha256:".length) + return path.posix.join("sha256", digest.slice(0, 2), digest) +} + +function parseArtifactKey(key: string) { + const match = /^sha256\/([a-f0-9]{2})\/([a-f0-9]{64})$/.exec(key) + if (!match?.[1] || !match[2] || match[2].slice(0, 2) !== match[1]) { + throw new Error("Artifact storage key is invalid.") + } + return { shard: match[1], digest: match[2] } +} + +function calculateContentDigest(bytes: Uint8Array): string { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}` +} + +function isWithinRoot(root: string, candidate: string): boolean { + return candidate === root || candidate.startsWith(`${root}${path.sep}`) +} + +async function secureRoot(root: string): Promise { + await mkdir(root, { recursive: true, mode: 0o700 }) + const status = await lstat(root) + if (status.isSymbolicLink()) rejectSymbolicLink() + if (!status.isDirectory()) throw new Error("Artifact storage root must be a directory.") + return realpath(root) +} + +async function secureDirectory( + root: string, + parent: string, + name: string, + create: boolean, +): Promise { + const candidate = path.join(parent, name) + if (create) { + try { + await mkdir(candidate, { mode: 0o700 }) + } catch (error) { + if (!isFileSystemError(error, "EEXIST")) throw error + } + } + const status = await lstat(candidate) + if (status.isSymbolicLink()) rejectSymbolicLink() + if (!status.isDirectory()) throw new Error("Artifact storage path is not a directory.") + const resolved = await realpath(candidate) + if (!isWithinRoot(root, resolved)) { + throw new Error("Artifact storage path escapes its root.") + } + return resolved +} + +async function resolveArtifactPath(root: string, key: string, createDirectories: boolean) { + const { shard, digest } = parseArtifactKey(key) + const resolvedRoot = await secureRoot(root) + const algorithmDirectory = await secureDirectory( + resolvedRoot, + resolvedRoot, + "sha256", + createDirectories, + ) + const shardDirectory = await secureDirectory( + resolvedRoot, + algorithmDirectory, + shard, + createDirectories, + ) + const destination = path.join(shardDirectory, digest) + if (!isWithinRoot(resolvedRoot, destination)) { + throw new Error("Artifact storage path escapes its root.") + } + return { destination, resolvedRoot } +} + +async function validateArtifactPath(resolvedRoot: string, destination: string) { + const status = await lstat(destination) + if (status.isSymbolicLink()) rejectSymbolicLink() + if (!status.isFile()) throw new Error("Artifact storage path is not a file.") + const resolved = await realpath(destination) + if (!isWithinRoot(resolvedRoot, resolved)) { + throw new Error("Artifact storage path escapes its root.") + } +} + +async function openArtifact(resolvedRoot: string, destination: string) { + await validateArtifactPath(resolvedRoot, destination) + return open(destination, constants.O_RDONLY | constants.O_NOFOLLOW) +} + +async function verifyExisting( + resolvedRoot: string, + destination: string, + contentDigest: string, +): Promise { + const file = await openArtifact(resolvedRoot, destination) + try { + const expected = Buffer.from(contentDigest.slice("sha256:".length), "hex") + const actual = createHash("sha256") + .update(await file.readFile()) + .digest() + if (!timingSafeEqual(expected, actual)) { + throw new Error("Stored artifact content does not match its key.") + } + } finally { + await file.close() + } +} + +async function syncDirectoryChain(directory: string, root: string): Promise { + let current = directory + while (true) { + const handle = await open(current, constants.O_RDONLY | constants.O_DIRECTORY) + try { + await handle.sync() + } finally { + await handle.close() + } + if (current === root) return + const parent = path.dirname(current) + if (!isWithinRoot(root, parent)) return + current = parent + } +} + +export function createLocalArtifactStorage(root: string): ArtifactStorage { + if (!path.isAbsolute(root)) { + throw new Error("HUB_ARTIFACT_STORAGE_PATH must be an absolute path.") + } + const normalizedRoot = path.resolve(root) + return { + async put(contentDigest: string, bytes: Uint8Array): Promise { + const key = artifactKey(contentDigest) + if (calculateContentDigest(bytes) !== contentDigest) { + throw new Error("Artifact bytes do not match the content digest.") + } + const { destination, resolvedRoot } = await resolveArtifactPath(normalizedRoot, key, true) + try { + await verifyExisting(resolvedRoot, destination, contentDigest) + await syncDirectoryChain(path.dirname(destination), resolvedRoot) + return { key, created: false } + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) throw error + } + + const temporary = `${destination}.${randomUUID()}.tmp` + const file = await open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ) + try { + try { + await file.writeFile(bytes) + await file.sync() + } finally { + await file.close() + } + try { + const resolved = await resolveArtifactPath(normalizedRoot, key, false) + if (resolved.destination !== destination) { + throw new Error("Artifact storage path changed during the write.") + } + await link(temporary, destination) + await syncDirectoryChain(path.dirname(destination), resolvedRoot) + return { key, created: true } + } catch (error) { + if (!isFileSystemError(error, "EEXIST")) throw error + await verifyExisting(resolvedRoot, destination, contentDigest) + await syncDirectoryChain(path.dirname(destination), resolvedRoot) + return { key, created: false } + } + } finally { + await rm(temporary, { force: true }) + } + }, + + async read(key: string): Promise { + const { destination, resolvedRoot } = await resolveArtifactPath(normalizedRoot, key, false) + const file = await openArtifact(resolvedRoot, destination) + try { + return await file.readFile() + } finally { + await file.close() + } + }, + + async remove(key: string): Promise { + try { + const { destination, resolvedRoot } = await resolveArtifactPath(normalizedRoot, key, false) + await validateArtifactPath(resolvedRoot, destination) + await unlink(destination) + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) throw error + } + }, + } +} diff --git a/hub/server/integrations/artifact-storage/types.ts b/hub/server/integrations/artifact-storage/types.ts new file mode 100644 index 00000000..43f08e07 --- /dev/null +++ b/hub/server/integrations/artifact-storage/types.ts @@ -0,0 +1,10 @@ +export interface StoredArtifact { + key: string + created: boolean +} + +export interface ArtifactStorage { + put(contentDigest: string, bytes: Uint8Array): Promise + read(key: string): Promise + remove(key: string): Promise +} diff --git a/hub/server/integrations/express/create-rate-limit/index.ts b/hub/server/integrations/express/create-rate-limit/index.ts new file mode 100644 index 00000000..18eb7f5a --- /dev/null +++ b/hub/server/integrations/express/create-rate-limit/index.ts @@ -0,0 +1,39 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +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() + + return function rateLimit(request: Request, _response: Response, next: NextFunction): void { + const now = Date.now() + const key = request.ip || request.socket.remoteAddress || "unknown" + for (const [entryKey, value] of entries) { + if (value.resetAt <= now) entries.delete(entryKey) + } + if (!entries.has(key) && entries.size >= maxEntries) { + const oldest = entries.keys().next().value + if (oldest) entries.delete(oldest) + } + const current = entries.get(key) + const entry = + !current || current.resetAt <= now ? { count: 0, resetAt: now + windowMilliseconds } : current + entry.count++ + entries.set(key, entry) + if (entry.count > limit) { + next(new AuthenticationError(429, "rate_limited", message)) + return + } + next() + } +} diff --git a/hub/server/integrations/express/prepare-authentication-request/index.ts b/hub/server/integrations/express/prepare-authentication-request/index.ts new file mode 100644 index 00000000..d5f45fd7 --- /dev/null +++ b/hub/server/integrations/express/prepare-authentication-request/index.ts @@ -0,0 +1,21 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function prepareAuthenticationRequest( + expectedOrigin: string, + request: Request, + response: Response, + next: NextFunction, +): void { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + if ( + !["GET", "HEAD", "OPTIONS"].includes(request.method) && + request.get("origin") !== expectedOrigin + ) { + next(new AuthenticationError(403, "origin_failed", "The request origin did not match.")) + return + } + next() +} diff --git a/hub/server/integrations/express/prepare-publishing-request/index.ts b/hub/server/integrations/express/prepare-publishing-request/index.ts new file mode 100644 index 00000000..a9e46676 --- /dev/null +++ b/hub/server/integrations/express/prepare-publishing-request/index.ts @@ -0,0 +1,11 @@ +import type { NextFunction, Request, Response } from "express" + +export function preparePublishingRequest( + _request: Request, + response: Response, + next: NextFunction, +): void { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + next() +} diff --git a/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts b/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts new file mode 100644 index 00000000..394d99be --- /dev/null +++ b/hub/server/integrations/express/read-trusted-proxy-hops/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" + +import { readTrustedProxyHops } from "#server/integrations/express/read-trusted-proxy-hops" + +describe("readTrustedProxyHops()", () => { + it("ignores forwarded client addresses unless an exact proxy count is configured", () => { + expect(readTrustedProxyHops({})).toBe(0) + expect(readTrustedProxyHops({ HUB_TRUSTED_PROXY_HOPS: "2" })).toBe(2) + }) + + it.each(["-1", "1.5", "11", "edge", " 1"])("rejects the invalid proxy count %s", (value) => { + expect(() => readTrustedProxyHops({ HUB_TRUSTED_PROXY_HOPS: value })).toThrow( + "HUB_TRUSTED_PROXY_HOPS", + ) + }) +}) diff --git a/hub/server/integrations/express/read-trusted-proxy-hops/index.ts b/hub/server/integrations/express/read-trusted-proxy-hops/index.ts new file mode 100644 index 00000000..67b8bdaf --- /dev/null +++ b/hub/server/integrations/express/read-trusted-proxy-hops/index.ts @@ -0,0 +1,11 @@ +export function readTrustedProxyHops(environment: NodeJS.ProcessEnv): number { + const value = environment.HUB_TRUSTED_PROXY_HOPS ?? "0" + if (!/^(?:0|[1-9]\d*)$/.test(value)) { + throw new Error("HUB_TRUSTED_PROXY_HOPS must be an integer from 0 through 10.") + } + const hops = Number(value) + if (hops > 10) { + throw new Error("HUB_TRUSTED_PROXY_HOPS must be an integer from 0 through 10.") + } + return hops +} diff --git a/hub/server/integrations/express/respond-administration-error/index.ts b/hub/server/integrations/express/respond-administration-error/index.ts new file mode 100644 index 00000000..f3ddf3ff --- /dev/null +++ b/hub/server/integrations/express/respond-administration-error/index.ts @@ -0,0 +1,20 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function respondAdministrationError( + error: unknown, + _request: Request, + response: Response, + next: NextFunction, +): void { + if (!(error instanceof AuthenticationError)) { + next(error) + return + } + if (error.code === "csrf_failed" || error.code === "origin_failed") { + response.status(403).json({ error: "Request not allowed" }) + return + } + response.status(error.status).json({ error: error.code, message: error.message }) +} diff --git a/hub/server/integrations/express/respond-authentication-error/index.ts b/hub/server/integrations/express/respond-authentication-error/index.ts new file mode 100644 index 00000000..778502c8 --- /dev/null +++ b/hub/server/integrations/express/respond-authentication-error/index.ts @@ -0,0 +1,26 @@ +import type { NextFunction, Request, Response } from "express" + +import { AuthenticationError } from "#server/utils/authentication-error" + +export function respondAuthenticationError( + error: unknown, + _request: Request, + response: Response, + next: NextFunction, +): void { + if (!(error instanceof AuthenticationError)) { + next(error) + return + } + const message = + error.status === 401 + ? "Authentication required" + : error.status === 403 + ? "Request not allowed" + : error.status === 429 + ? "Too many requests" + : error.status >= 500 + ? "Authentication unavailable" + : "Authentication failed" + response.status(error.status).json({ error: message }) +} diff --git a/hub/server/integrations/github/check-repository-admission/constants.ts b/hub/server/integrations/github/check-repository-admission/constants.ts new file mode 100644 index 00000000..d06b0a7a --- /dev/null +++ b/hub/server/integrations/github/check-repository-admission/constants.ts @@ -0,0 +1,28 @@ +import { z } from "zod" + +export const installedRepositoriesSchema = z.object({ + repositories: z.array( + z.object({ + id: z.number().int().positive(), + full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1), + }), + ), +}) + +export const collaboratorPermissionSchema = z.object({ + user: z.object({ id: z.number().int().positive() }), + permission: z.enum(["admin", "maintain", "write", "triage", "read", "none"]), + role_name: z.string().min(1).max(255).nullable().optional(), +}) + +export const repositorySchema = z.object({ + id: z.number().int().positive(), + full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/), + name: z.string().min(1), + owner: z.object({ login: z.string().min(1) }), + default_branch: z.string().min(1), + visibility: z.enum(["internal", "private", "public"]), +}) diff --git a/hub/server/integrations/github/check-repository-admission/index.ts b/hub/server/integrations/github/check-repository-admission/index.ts new file mode 100644 index 00000000..014c4ea1 --- /dev/null +++ b/hub/server/integrations/github/check-repository-admission/index.ts @@ -0,0 +1,57 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { createInstallationToken } from "#server/integrations/github/create-installation-token" +import { + collaboratorPermissionSchema, + installedRepositoriesSchema, +} from "#server/integrations/github/check-repository-admission/constants" +import { GitHubApiError } from "#server/integrations/github/github-api-error" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { AuthenticationError } from "#server/utils/authentication-error" + +async function hasPermission( + token: string, + profile: GitHubUserProfile, + repository: string, +): Promise { + let response: unknown + try { + response = await requestGitHubApi( + `/repos/${repository}/collaborators/${encodeURIComponent(profile.username)}/permission`, + token, + ) + } catch (error) { + if (error instanceof GitHubApiError && error.status === 404) return false + throw error + } + const permission = collaboratorPermissionSchema.parse(response) + if (String(permission.user.id) !== profile.subject) { + throw new AuthenticationError( + 403, + "identity_mismatch", + "The repository permission identity did not match.", + ) + } + return permission.permission !== "none" +} + +export async function checkRepositoryAdmission( + credentials: GitHubCredentials, + profile: GitHubUserProfile, +): Promise { + const token = await createInstallationToken(credentials) + for (let page = 1; page <= 100; page++) { + const response = installedRepositoriesSchema.parse( + await requestGitHubApi(`/installation/repositories?per_page=100&page=${page}`, token), + ) + for (const repository of response.repositories) { + if (await hasPermission(token, profile, repository.full_name)) return true + } + if (response.repositories.length < 100) return false + } + throw new AuthenticationError( + 503, + "repository_limit", + "Repository admission exceeded the supported page limit.", + ) +} diff --git a/hub/server/integrations/github/create-app-token/index.ts b/hub/server/integrations/github/create-app-token/index.ts new file mode 100644 index 00000000..8423e0c8 --- /dev/null +++ b/hub/server/integrations/github/create-app-token/index.ts @@ -0,0 +1,16 @@ +import { sign } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function createAppToken(credentials: GitHubCredentials): string { + const now = Math.floor(Date.now() / 1000) + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url") + const payload = Buffer.from( + JSON.stringify({ iat: now - 60, exp: now + 540, iss: credentials.appId }), + ).toString("base64url") + const unsigned = `${header}.${payload}` + const signature = sign("RSA-SHA256", Buffer.from(unsigned), credentials.privateKey).toString( + "base64url", + ) + return `${unsigned}.${signature}` +} diff --git a/hub/server/integrations/github/create-installation-token/constants.ts b/hub/server/integrations/github/create-installation-token/constants.ts new file mode 100644 index 00000000..1c01e5df --- /dev/null +++ b/hub/server/integrations/github/create-installation-token/constants.ts @@ -0,0 +1,3 @@ +import { z } from "zod" + +export const installationTokenSchema = z.object({ token: z.string().min(1) }) diff --git a/hub/server/integrations/github/create-installation-token/index.ts b/hub/server/integrations/github/create-installation-token/index.ts new file mode 100644 index 00000000..f7c36b28 --- /dev/null +++ b/hub/server/integrations/github/create-installation-token/index.ts @@ -0,0 +1,13 @@ +import { createAppToken } from "#server/integrations/github/create-app-token" +import { installationTokenSchema } from "#server/integrations/github/create-installation-token/constants" +import { requestGitHubApi } from "#server/integrations/github/request-github-api" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export async function createInstallationToken(credentials: GitHubCredentials): Promise { + const response = await requestGitHubApi( + `/app/installations/${credentials.installationId}/access_tokens`, + createAppToken(credentials), + "POST", + ) + return installationTokenSchema.parse(response).token +} diff --git a/hub/server/integrations/github/create-user-auth-client/index.test.ts b/hub/server/integrations/github/create-user-auth-client/index.test.ts new file mode 100644 index 00000000..85fb1e34 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/index.test.ts @@ -0,0 +1,153 @@ +import { createHash } from "node:crypto" +import { describe, expect, it, vi } from "vitest" + +import { createGitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client" + +const configuration = { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com" as const, +} + +describe("createGitHubUserAuthClient()", () => { + it("builds a state-bound S256 authorization URL without repository scopes", () => { + const client = createGitHubUserAuthClient(configuration) + const verifier = "pkce-verifier-fixture" + + const url = new URL(client.authorizationUrl("state-fixture", verifier)) + + expect(url.origin + url.pathname).toBe("https://github.com/login/oauth/authorize") + expect(Object.fromEntries(url.searchParams)).toEqual({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + state: "state-fixture", + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + }) + expect(url.searchParams.has("scope")).toBe(false) + }) + + it("keeps the user access token transient while returning the immutable GitHub subject", async () => { + const accessToken = "github-user-access-token-fixture" + const requests: Array<{ input: string; init?: RequestInit }> = [] + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const requestUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push({ input: requestUrl, init }) + if (requests.length === 1) { + return Promise.resolve( + new Response(JSON.stringify({ access_token: accessToken, token_type: "bearer" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + } + return Promise.resolve( + new Response( + JSON.stringify({ + id: 42, + login: "octocat", + name: "The Octocat", + avatar_url: "https://avatars.githubusercontent.com/u/42", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + }), + ) + const client = createGitHubUserAuthClient(configuration) + + const profile = await client.identify("one-use-code", "pkce-verifier") + + expect(profile).toEqual({ + subject: "42", + username: "octocat", + displayName: "The Octocat", + avatarUrl: "https://avatars.githubusercontent.com/u/42", + }) + const tokenRequest = requests[0] + const userRequest = requests[1] + expect(tokenRequest).toBeDefined() + expect(userRequest).toBeDefined() + expect(tokenRequest!).toMatchObject({ + input: "https://github.com/login/oauth/access_token", + init: { + method: "POST", + redirect: "error", + }, + }) + const tokenBody = tokenRequest!.init?.body + if (typeof tokenBody !== "string") throw new Error("Expected a JSON token request body") + expect(JSON.parse(tokenBody) as unknown).toMatchObject({ + code: "one-use-code", + code_verifier: "pkce-verifier", + redirect_uri: configuration.callbackUrl, + }) + expect(userRequest!).toMatchObject({ + input: "https://api.github.com/user", + init: { redirect: "error" }, + }) + expect(new Headers(userRequest!.init?.headers).get("authorization")).toBe( + `Bearer ${accessToken}`, + ) + expect(JSON.stringify(profile)).not.toContain(accessToken) + }) + + it("rejects malformed token and profile responses instead of constructing a partial identity", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: 7 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ) + const client = createGitHubUserAuthClient(configuration) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 401, + code: "github_denied", + }) + + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ access_token: "token", token_type: "bearer" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: "mutable-login", login: "octocat" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_invalid_response", + }) + }) + + it("maps network and non-success responses to generic availability failures", async () => { + const client = createGitHubUserAuthClient(configuration) + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("token leaked in transport error"))) + + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_unavailable", + message: "GitHub is unavailable.", + }) + + vi.mocked(fetch).mockResolvedValue(new Response("private upstream failure", { status: 502 })) + await expect(client.identify("code", "verifier")).rejects.toMatchObject({ + status: 503, + code: "github_unavailable", + message: "GitHub is unavailable.", + }) + }) +}) diff --git a/hub/server/integrations/github/create-user-auth-client/index.ts b/hub/server/integrations/github/create-user-auth-client/index.ts new file mode 100644 index 00000000..fe323255 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/index.ts @@ -0,0 +1,17 @@ +import { createUserAuthorizationUrl } from "#server/integrations/github/create-user-authorization-url" +import type { + GitHubUserAuthClient, + GitHubUserAuthConfiguration, +} from "#server/integrations/github/create-user-auth-client/types" +import { exchangeUserAuthorization } from "#server/integrations/github/exchange-user-authorization" +import { requestGitHubUser } from "#server/integrations/github/request-github-user" + +export function createGitHubUserAuthClient( + configuration: GitHubUserAuthConfiguration, +): GitHubUserAuthClient { + return { + authorizationUrl: createUserAuthorizationUrl.bind(null, configuration), + identify: async (code, pkceVerifier) => + requestGitHubUser(await exchangeUserAuthorization(configuration, code, pkceVerifier)), + } +} diff --git a/hub/server/integrations/github/create-user-auth-client/types.ts b/hub/server/integrations/github/create-user-auth-client/types.ts new file mode 100644 index 00000000..f47e0447 --- /dev/null +++ b/hub/server/integrations/github/create-user-auth-client/types.ts @@ -0,0 +1,18 @@ +export interface GitHubUserAuthConfiguration { + clientId: string + clientSecret: string + callbackUrl: string + issuer: "https://github.com" +} + +export interface GitHubUserProfile { + subject: string + username: string + displayName: string | null + avatarUrl: string | null +} + +export interface GitHubUserAuthClient { + authorizationUrl(state: string, pkceVerifier: string): string + identify(code: string, pkceVerifier: string): Promise +} diff --git a/hub/server/integrations/github/create-user-authorization-url/index.ts b/hub/server/integrations/github/create-user-authorization-url/index.ts new file mode 100644 index 00000000..80010614 --- /dev/null +++ b/hub/server/integrations/github/create-user-authorization-url/index.ts @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto" + +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" + +export function createUserAuthorizationUrl( + configuration: GitHubUserAuthConfiguration, + state: string, + pkceVerifier: string, +): string { + const url = new URL("https://github.com/login/oauth/authorize") + url.search = new URLSearchParams({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + state, + code_challenge: createHash("sha256").update(pkceVerifier).digest("base64url"), + code_challenge_method: "S256", + }).toString() + return url.href +} diff --git a/hub/server/integrations/github/decrypt-github-credentials/index.ts b/hub/server/integrations/github/decrypt-github-credentials/index.ts new file mode 100644 index 00000000..d25b1c18 --- /dev/null +++ b/hub/server/integrations/github/decrypt-github-credentials/index.ts @@ -0,0 +1,22 @@ +import { createDecipheriv } from "node:crypto" + +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import { validateGitHubCredentials } from "#server/integrations/github/validate-github-credentials" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function decryptGitHubCredentials( + key: Buffer, + encryptedCredentials: string | null, +): GitHubCredentials | null { + if (encryptedCredentials === null) return null + try { + const data = Buffer.from(encryptedCredentials, "base64") + const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(0, 12)) + decipher.setAAD(credentialContext) + decipher.setAuthTag(data.subarray(12, 28)) + const plaintext = Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]) + return validateGitHubCredentials(JSON.parse(plaintext.toString("utf8"))) + } catch { + throw new Error("Cannot decrypt GitHub App credentials. Restore the configured encryption key.") + } +} diff --git a/hub/server/integrations/github/encrypt-github-credentials/constants.ts b/hub/server/integrations/github/encrypt-github-credentials/constants.ts new file mode 100644 index 00000000..45c9388c --- /dev/null +++ b/hub/server/integrations/github/encrypt-github-credentials/constants.ts @@ -0,0 +1 @@ +export const credentialContext = Buffer.from("replica:github-app:v1") diff --git a/hub/server/integrations/github/encrypt-github-credentials/index.ts b/hub/server/integrations/github/encrypt-github-credentials/index.ts new file mode 100644 index 00000000..56876cdc --- /dev/null +++ b/hub/server/integrations/github/encrypt-github-credentials/index.ts @@ -0,0 +1,12 @@ +import { createCipheriv, randomBytes } from "node:crypto" + +import { credentialContext } from "#server/integrations/github/encrypt-github-credentials/constants" +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" + +export function encryptGitHubCredentials(key: Buffer, credentials: GitHubCredentials): string { + const nonce = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", key, nonce) + cipher.setAAD(credentialContext) + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(credentials)), cipher.final()]) + return Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString("base64") +} diff --git a/hub/server/integrations/github/exchange-user-authorization/constants.ts b/hub/server/integrations/github/exchange-user-authorization/constants.ts new file mode 100644 index 00000000..e40d8a1f --- /dev/null +++ b/hub/server/integrations/github/exchange-user-authorization/constants.ts @@ -0,0 +1,6 @@ +import { z } from "zod" + +export const accessTokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.literal("bearer"), +}) diff --git a/hub/server/integrations/github/exchange-user-authorization/index.ts b/hub/server/integrations/github/exchange-user-authorization/index.ts new file mode 100644 index 00000000..0271af76 --- /dev/null +++ b/hub/server/integrations/github/exchange-user-authorization/index.ts @@ -0,0 +1,42 @@ +import type { GitHubUserAuthConfiguration } from "#server/integrations/github/create-user-auth-client/types" +import { accessTokenSchema } from "#server/integrations/github/exchange-user-authorization/constants" +import { AuthenticationError } from "#server/utils/authentication-error" + +export async function exchangeUserAuthorization( + configuration: GitHubUserAuthConfiguration, + code: string, + pkceVerifier: string, +): Promise { + let response: Response + try { + response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: configuration.clientId, + client_secret: configuration.clientSecret, + code, + code_verifier: pkceVerifier, + redirect_uri: configuration.callbackUrl, + }), + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + if (!response.ok) { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + let body: unknown + try { + body = await response.json() + } catch { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + const parsed = accessTokenSchema.safeParse(body) + if (!parsed.success) { + throw new AuthenticationError(401, "github_denied", "GitHub denied authorization.") + } + return parsed.data.access_token +} diff --git a/hub/server/integrations/github/github-api-error/index.ts b/hub/server/integrations/github/github-api-error/index.ts new file mode 100644 index 00000000..cf23cbc9 --- /dev/null +++ b/hub/server/integrations/github/github-api-error/index.ts @@ -0,0 +1,5 @@ +export class GitHubApiError extends Error { + constructor(readonly status: number) { + super("GitHub API request failed.") + } +} diff --git a/hub/server/integrations/github/github-error/index.ts b/hub/server/integrations/github/github-error/index.ts new file mode 100644 index 00000000..9be9cfe6 --- /dev/null +++ b/hub/server/integrations/github/github-error/index.ts @@ -0,0 +1,10 @@ +import type { GitHubErrorCode } from "#server/integrations/github/github-error/types" + +export class GitHubError extends Error { + code: GitHubErrorCode + + constructor(code: GitHubErrorCode, message: string) { + super(message) + this.code = code + } +} diff --git a/hub/server/integrations/github/github-error/types.ts b/hub/server/integrations/github/github-error/types.ts new file mode 100644 index 00000000..a5fdfb96 --- /dev/null +++ b/hub/server/integrations/github/github-error/types.ts @@ -0,0 +1 @@ +export type GitHubErrorCode = "invalid_credentials" diff --git a/hub/server/integrations/github/request-github-api/index.ts b/hub/server/integrations/github/request-github-api/index.ts new file mode 100644 index 00000000..b2a56334 --- /dev/null +++ b/hub/server/integrations/github/request-github-api/index.ts @@ -0,0 +1,26 @@ +import { GitHubApiError } from "#server/integrations/github/github-api-error" + +export async function requestGitHubApi( + path: string, + token: string, + method = "GET", +): Promise { + let response: Response + try { + response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new GitHubApiError(0) + } + if (!response.ok) throw new GitHubApiError(response.status) + if (response.status === 204) return null + return response.json() +} diff --git a/hub/server/integrations/github/request-github-user/constants.ts b/hub/server/integrations/github/request-github-user/constants.ts new file mode 100644 index 00000000..7b5e7b7b --- /dev/null +++ b/hub/server/integrations/github/request-github-user/constants.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const githubUserSchema = z.object({ + id: z.number().int().positive(), + login: z.string().min(1).max(255), + name: z.string().max(255).nullable(), + avatar_url: z.url().nullable(), +}) diff --git a/hub/server/integrations/github/request-github-user/index.ts b/hub/server/integrations/github/request-github-user/index.ts new file mode 100644 index 00000000..104afe4a --- /dev/null +++ b/hub/server/integrations/github/request-github-user/index.ts @@ -0,0 +1,39 @@ +import type { GitHubUserProfile } from "#server/integrations/github/create-user-auth-client/types" +import { githubUserSchema } from "#server/integrations/github/request-github-user/constants" +import { AuthenticationError } from "#server/utils/authentication-error" + +export async function requestGitHubUser(accessToken: string): Promise { + let response: Response + try { + response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(10_000), + redirect: "error", + }) + } catch { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + if (!response.ok) { + throw new AuthenticationError(503, "github_unavailable", "GitHub is unavailable.") + } + let body: unknown + try { + body = await response.json() + } catch { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + const parsed = githubUserSchema.safeParse(body) + if (!parsed.success) { + throw new AuthenticationError(503, "github_invalid_response", "GitHub returned invalid data.") + } + return { + subject: String(parsed.data.id), + username: parsed.data.login, + displayName: parsed.data.name, + avatarUrl: parsed.data.avatar_url, + } +} diff --git a/hub/server/integrations/github/validate-github-credentials/constants.ts b/hub/server/integrations/github/validate-github-credentials/constants.ts new file mode 100644 index 00000000..3a497c7e --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/constants.ts @@ -0,0 +1,16 @@ +import { z } from "zod" + +const githubIdSchema = z + .string() + .regex(/^[1-9]\d*$/) + .max(30) + +export const credentialsSchema = z + .object({ + appId: githubIdSchema, + installationId: githubIdSchema, + clientId: z.string().min(1).max(200), + clientSecret: z.string().min(1).max(1000), + privateKey: z.string().min(1).max(16384), + }) + .strict() diff --git a/hub/server/integrations/github/validate-github-credentials/index.ts b/hub/server/integrations/github/validate-github-credentials/index.ts new file mode 100644 index 00000000..744553d5 --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/index.ts @@ -0,0 +1,30 @@ +import { createPrivateKey } from "node:crypto" + +import type { GitHubCredentials } from "#server/integrations/github/validate-github-credentials/types" +import { GitHubError } from "#server/integrations/github/github-error" + +import { credentialsSchema } from "#server/integrations/github/validate-github-credentials/constants" + +export function validateGitHubCredentials(input: unknown): GitHubCredentials { + const parsed = credentialsSchema.safeParse(input) + if (!parsed.success) { + throw new GitHubError( + "invalid_credentials", + "Supply the GitHub App ID, installation ID, client ID, client secret, and RSA private key.", + ) + } + + try { + const key = createPrivateKey(parsed.data.privateKey) + if (key.asymmetricKeyType !== "rsa" || (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) { + throw new Error("Invalid key") + } + } catch { + throw new GitHubError( + "invalid_credentials", + "The GitHub App private key must be an RSA key of at least 2048 bits.", + ) + } + + return parsed.data +} diff --git a/hub/server/integrations/github/validate-github-credentials/types.ts b/hub/server/integrations/github/validate-github-credentials/types.ts new file mode 100644 index 00000000..d45f9941 --- /dev/null +++ b/hub/server/integrations/github/validate-github-credentials/types.ts @@ -0,0 +1,7 @@ +export interface GitHubCredentials { + appId: string + installationId: string + clientId: string + clientSecret: string + privateKey: string +} diff --git a/hub/server/integrations/oidc/create-client/index.test.ts b/hub/server/integrations/oidc/create-client/index.test.ts new file mode 100644 index 00000000..250499c9 --- /dev/null +++ b/hub/server/integrations/oidc/create-client/index.test.ts @@ -0,0 +1,408 @@ +import { calculateJwkThumbprint, exportJWK, generateKeyPair, SignJWT } from "jose" +import { describe, expect, it, vi } from "vitest" +import type { Mock } from "vitest" + +import { createOidcClient } from "#server/integrations/oidc/create-client" + +const issuer = "https://login.example.test/tenant/v2.0" +const configuration = { + issuer, + clientId: "hub-client", + clientSecret: "client-secret-fixture", + clientCredentialReference: "HUB_OIDC_CLIENT_SECRET", + callbackUrl: "https://hub.example.test/api/auth/oidc/callback", + scopes: ["openid", "profile", "email"], + memberRole: "Hub.User", + administratorRole: "Hub.Administrator", + membershipPolicy: "jit-assignment" as const, + enabled: true, + providerRevision: 1, +} + +async function signingKey() { + const pair = await generateKeyPair("RS256") + const jwk = await exportJWK(pair.publicKey) + const kid = await calculateJwkThumbprint(jwk) + return { ...pair, jwk: { ...jwk, kid, alg: "RS256", use: "sig" }, kid } +} + +async function idToken( + key: Awaited>, + overrides: Record = {}, +) { + const now = Math.floor(Date.now() / 1_000) + const claims = { + sub: "immutable-subject", + nonce: "expected-nonce", + iss: issuer, + aud: configuration.clientId, + roles: ["Hub.User"], + preferred_username: "member@example.test", + email: "member@example.test", + name: "Member Name", + picture: "https://images.example.test/member.png", + iat: now, + exp: now + 300, + ...overrides, + } + return new SignJWT(claims).setProtectedHeader({ alg: "RS256", kid: key.kid }).sign(key.privateKey) +} + +function json(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }) +} + +function providerFetch(options: { + token?: string + keys?: unknown[] + discovery?: Record +}): Mock { + return vi.fn((input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input) + if (url.pathname.endsWith("/.well-known/openid-configuration")) { + return Promise.resolve( + json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/keys`, + id_token_signing_alg_values_supported: ["RS256"], + ...options.discovery, + }), + ) + } + if (url.pathname.endsWith("/token")) return Promise.resolve(json({ id_token: options.token })) + if (url.pathname.endsWith("/keys")) { + return Promise.resolve(json({ keys: options.keys ?? [] })) + } + return Promise.resolve(json({}, 404)) + }) +} + +describe("createOidcClient()", () => { + it("rejects discovery metadata whose issuer is not the exact configured issuer", async () => { + const request = providerFetch({ + discovery: { issuer: `${issuer}/different` }, + }) + const client = createOidcClient(configuration, { fetch: request }) + + await expect(client.authorizationUrl("state", "pkce-verifier", "nonce")).rejects.toThrow( + "OIDC discovery returned a different issuer.", + ) + }) + + it.each([ + ["configured HTTP issuer", { ...configuration, issuer: "http://login.example.test/tenant" }], + ["configured loopback issuer", { ...configuration, issuer: "https://127.0.0.1/tenant" }], + ])("rejects a %s before making a provider request", (_name, unsafeConfiguration) => { + const request = vi.fn() + + expect(() => createOidcClient(unsafeConfiguration, { fetch: request })).toThrow( + "OIDC URLs must use a public HTTPS origin.", + ) + expect(request).not.toHaveBeenCalled() + }) + + it.each([ + ["HTTP authorization endpoint", { authorization_endpoint: "http://login.example.test/auth" }], + ["cross-origin token endpoint", { token_endpoint: "https://tokens.example.test/tenant/token" }], + ["IP-address JWKS endpoint", { jwks_uri: "https://192.0.2.1/keys" }], + ])("rejects discovery with a %s", async (_name, discovery) => { + const client = createOidcClient(configuration, { + fetch: providerFetch({ discovery }), + }) + + await expect(client.authorizationUrl("state", "pkce-verifier", "nonce")).rejects.toThrow( + "OIDC URLs must use a public HTTPS origin.", + ) + }) + + it.each([ + ["no advertised signing algorithms", { id_token_signing_alg_values_supported: [] }], + [ + "only symmetric signing algorithms", + { id_token_signing_alg_values_supported: ["HS256", "HS512"] }, + ], + ])("rejects discovery with %s", async (_name, discovery) => { + const client = createOidcClient(configuration, { + fetch: providerFetch({ discovery }), + }) + + await expect(client.authorizationUrl("state", "pkce-verifier", "nonce")).rejects.toThrow() + }) + + it("creates an authorization URL with the exact callback, scopes, state, nonce, and S256 PKCE", async () => { + const client = createOidcClient(configuration, { fetch: providerFetch({}) }) + + const url = new URL( + await client.authorizationUrl("opaque-state", "pkce-verifier", "one-use-nonce"), + ) + + expect(url.origin + url.pathname).toBe(`${issuer}/authorize`) + expect(Object.fromEntries(url.searchParams)).toEqual({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + response_type: "code", + response_mode: "query", + scope: "openid profile email", + state: "opaque-state", + nonce: "one-use-nonce", + code_challenge: "S-YYjGPeiHjsbIXpqrbVjcGUQn7X-4T468hBrBqm8pA", + code_challenge_method: "S256", + }) + }) + + it("requests a new provider login for fresh authorization", async () => { + const client = createOidcClient(configuration, { fetch: providerFetch({}) }) + + const url = new URL( + await client.authorizationUrl("opaque-state", "pkce-verifier", "one-use-nonce", { + fresh: true, + }), + ) + + expect(url.searchParams.get("prompt")).toBe("login") + expect(url.searchParams.get("max_age")).toBe("0") + }) + + it("exchanges the code with the verifier and maps administrator claims without returning tokens", async () => { + const key = await signingKey() + const token = await idToken(key, { + roles: ["Hub.User", "Hub.Administrator", 42], + oid: "A0E1D2C3-B4A5-4678-9ABC-DEF012345678", + }) + const request = providerFetch({ token, keys: [key.jwk] }) + const client = createOidcClient(configuration, { fetch: request }) + + const profile = await client.identify("one-use-code", "pkce-verifier", "expected-nonce") + + expect(profile).toEqual({ + subject: "immutable-subject", + directoryObjectId: "a0e1d2c3-b4a5-4678-9abc-def012345678", + username: "member@example.test", + email: "member@example.test", + displayName: "Member Name", + avatarUrl: "https://images.example.test/member.png", + role: "administrator", + }) + const tokenRequest = request.mock.calls.find(([input]) => + new URL(input instanceof Request ? input.url : input).pathname.endsWith("/token"), + ) + expect(tokenRequest).toBeDefined() + const init = tokenRequest?.[1] + expect(init?.method).toBe("POST") + if (!(init?.body instanceof URLSearchParams)) { + throw new Error("Expected the token request to use URL-encoded parameters.") + } + expect(init.body.toString()).toBe( + new URLSearchParams({ + grant_type: "authorization_code", + code: "one-use-code", + redirect_uri: configuration.callbackUrl, + client_id: configuration.clientId, + client_secret: configuration.clientSecret, + code_verifier: "pkce-verifier", + }).toString(), + ) + expect(JSON.stringify(profile)).not.toContain(token) + }) + + it.each([ + ["the lower clock-skew boundary", -5], + ["a long provider flow after the authorization attempt started", 30], + ["the future clock-skew boundary", 9 * 60 + 5], + ])("accepts fresh identification at %s", async (_name, secondsAfterAttemptStart) => { + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(new Date("2026-10-29T10:00:00.000Z")) + const authenticatedAfter = new Date("2026-10-29T09:51:00.000Z") + const key = await signingKey() + const token = await idToken(key, { + auth_time: Math.floor(authenticatedAfter.getTime() / 1_000) + secondsAfterAttemptStart, + }) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [key.jwk] }), + }) + + await expect( + client.identify("code", "verifier", "expected-nonce", { + authenticatedAfter, + }), + ).resolves.toMatchObject({ subject: "immutable-subject" }) + }) + + it.each([ + ["missing", undefined], + ["before the authorization attempt", -6], + ["materially future", 9 * 60 + 6], + ])("rejects fresh identification with a %s authentication time", async (_name, authTime) => { + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(new Date("2026-10-29T10:00:00.000Z")) + const authenticatedAfter = new Date("2026-10-29T09:51:00.000Z") + const key = await signingKey() + const token = await idToken(key, { + auth_time: + authTime === undefined + ? undefined + : Math.floor(authenticatedAfter.getTime() / 1_000) + authTime, + }) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [key.jwk] }), + }) + + await expect( + client.identify("code", "verifier", "expected-nonce", { + authenticatedAfter, + }), + ).rejects.toThrow("OIDC authentication is not fresh.") + }) + + it.each([ + ["nonce", { nonce: "different-nonce" }], + ["audience", { aud: "different-client" }], + ["issuer", { iss: "https://different.example.test/tenant" }], + ["authorized party", { azp: "different-client" }], + ["multi-audience authorized party", { aud: ["hub-client", "other-client"] }], + ["issued-at", { iat: Math.floor(Date.now() / 1_000) + 60 }], + ["expiration", { exp: undefined }], + [ + "lifetime", + { + iat: Math.floor(Date.now() / 1_000) - 700, + exp: Math.floor(Date.now() / 1_000) + 300, + }, + ], + ])("rejects an ID token with an invalid %s claim", async (_name, claims) => { + const key = await signingKey() + const token = await idToken(key, claims) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [key.jwk] }), + }) + + await expect(client.identify("code", "verifier", "expected-nonce")).rejects.toThrow() + }) + + it("rejects a malformed directory object ID instead of treating it as an assignment identifier", async () => { + const key = await signingKey() + const token = await idToken(key, { oid: "not-a-directory-object-id" }) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [key.jwk] }), + }) + + await expect(client.identify("code", "verifier", "expected-nonce")).rejects.toThrow( + "OIDC directory object ID is invalid.", + ) + }) + + it("rejects an ID token signed by a key outside the provider JWKS", async () => { + const trusted = await signingKey() + const untrusted = await signingKey() + const token = await idToken(untrusted) + const client = createOidcClient(configuration, { + fetch: providerFetch({ token, keys: [trusted.jwk] }), + }) + + await expect(client.identify("code", "verifier", "expected-nonce")).rejects.toThrow() + }) + + it("reuses cached discovery and JWKS for known signing keys", async () => { + const key = await signingKey() + const token = await idToken(key) + const request = providerFetch({ token, keys: [key.jwk] }) + const client = createOidcClient(configuration, { fetch: request }) + + await client.identify("first-code", "first-verifier", "expected-nonce") + await client.identify("second-code", "second-verifier", "expected-nonce") + + const paths = request.mock.calls.map( + ([input]) => new URL(input instanceof Request ? input.url : input).pathname, + ) + expect(paths.filter((path) => path.endsWith("/.well-known/openid-configuration"))).toHaveLength( + 1, + ) + expect(paths.filter((path) => path.endsWith("/keys"))).toHaveLength(1) + expect(paths.filter((path) => path.endsWith("/token"))).toHaveLength(2) + }) + + it("refreshes JWKS once when a rotated token uses an unknown key id", async () => { + vi.useFakeTimers({ toFake: ["Date"] }) + vi.setSystemTime(new Date("2026-10-27T18:00:00.000Z")) + const oldKey = await signingKey() + const rotatedKey = await signingKey() + const tokens = [await idToken(oldKey), await idToken(rotatedKey)] + let jwksRequests = 0 + const request = vi.fn((input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input) + if (url.pathname.endsWith("/.well-known/openid-configuration")) { + return Promise.resolve( + json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/keys`, + id_token_signing_alg_values_supported: ["RS256"], + }), + ) + } + if (url.pathname.endsWith("/token")) { + return Promise.resolve(json({ id_token: tokens.shift() })) + } + if (url.pathname.endsWith("/keys")) { + jwksRequests += 1 + return Promise.resolve( + json({ keys: jwksRequests === 1 ? [oldKey.jwk] : [oldKey.jwk, rotatedKey.jwk] }), + ) + } + return Promise.resolve(json({}, 404)) + }) + const client = createOidcClient(configuration, { fetch: request }) + + await client.identify("first-code", "first-verifier", "expected-nonce") + vi.advanceTimersByTime(5_001) + await client.identify("rotated-code", "rotated-verifier", "expected-nonce") + + expect(jwksRequests).toBe(2) + }) + + it.each(["discovery", "token", "jwks"] as const)( + "fails closed during a provider %s outage", + async (failedSurface) => { + const key = await signingKey() + const token = await idToken(key) + const request = vi.fn((input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input) + if ( + (failedSurface === "discovery" && + url.pathname.endsWith("/.well-known/openid-configuration")) || + (failedSurface === "token" && url.pathname.endsWith("/token")) || + (failedSurface === "jwks" && url.pathname.endsWith("/keys")) + ) { + return Promise.resolve(json({ error: "provider unavailable" }, 503)) + } + if (url.pathname.endsWith("/.well-known/openid-configuration")) { + return Promise.resolve( + json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/keys`, + id_token_signing_alg_values_supported: ["RS256"], + }), + ) + } + if (url.pathname.endsWith("/token")) return Promise.resolve(json({ id_token: token })) + if (url.pathname.endsWith("/keys")) return Promise.resolve(json({ keys: [key.jwk] })) + return Promise.resolve(json({}, 404)) + }) + const client = createOidcClient(configuration, { fetch: request }) + + const operation = + failedSurface === "discovery" + ? client.authorizationUrl("state", "verifier", "nonce") + : client.identify("code", "verifier", "expected-nonce") + await expect(operation).rejects.toThrow() + }, + ) +}) diff --git a/hub/server/integrations/oidc/create-client/index.ts b/hub/server/integrations/oidc/create-client/index.ts new file mode 100644 index 00000000..62273111 --- /dev/null +++ b/hub/server/integrations/oidc/create-client/index.ts @@ -0,0 +1,223 @@ +import { createHash } from "node:crypto" +import { isIP } from "node:net" + +import { createRemoteJWKSet, customFetch, jwtVerify } from "jose" +import { z } from "zod" + +import type { + OidcClient, + OidcClientOptions, + OidcConfiguration, + OidcProfile, +} from "#server/integrations/oidc/create-client/types" + +const discoverySchema = z.object({ + issuer: z.string(), + authorization_endpoint: z.string(), + token_endpoint: z.string(), + jwks_uri: z.string(), + id_token_signing_alg_values_supported: z.array(z.string()).min(1), +}) + +const tokenSchema = z.object({ + id_token: z.string().min(1), +}) + +const allowedSigningAlgorithms = new Set([ + "RS256", + "RS384", + "RS512", + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES512", + "EdDSA", +]) + +function publicHttpsUrl(value: string, expectedOrigin?: string): URL { + const url = new URL(value) + const hostname = url.hostname.toLowerCase() + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.hash || + (expectedOrigin && url.origin !== expectedOrigin) || + hostname === "localhost" || + hostname.endsWith(".localhost") || + isIP(hostname) !== 0 + ) { + throw new Error("OIDC URLs must use a public HTTPS origin.") + } + return url +} + +function codeChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url") +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null +} + +function optionalUuid(value: unknown): string | null { + if (value === undefined) return null + const parsed = z.uuid().safeParse(value) + if (!parsed.success) throw new Error("OIDC directory object ID is invalid.") + return parsed.data.toLowerCase() +} + +export function createOidcClient( + configuration: OidcConfiguration, + options: OidcClientOptions = {}, +): OidcClient { + const request = options.fetch ?? fetch + const timeoutMilliseconds = options.timeoutMilliseconds ?? 5_000 + const discoveryCacheMilliseconds = options.discoveryCacheMilliseconds ?? 5 * 60_000 + const configuredIssuer = publicHttpsUrl(configuration.issuer).href.replace(/\/$/, "") + let cachedDiscovery: + | { + expiresAt: number + issuer: string + authorizationEndpoint: URL + tokenEndpoint: URL + signingAlgorithms: string[] + jwks: ReturnType + } + | undefined + + async function fetchJson(url: URL, init?: RequestInit): Promise { + const response = await request(url, { + ...init, + redirect: "error", + signal: AbortSignal.timeout(timeoutMilliseconds), + }) + if (!response.ok) throw new Error(`OIDC request failed with status ${response.status}.`) + return response.json() + } + + async function discovery() { + if (cachedDiscovery && cachedDiscovery.expiresAt > Date.now()) return cachedDiscovery + const discoveryUrl = new URL(`${configuredIssuer}/.well-known/openid-configuration`) + const metadata = discoverySchema.parse(await fetchJson(discoveryUrl)) + if (metadata.issuer !== configuredIssuer) { + throw new Error("OIDC discovery returned a different issuer.") + } + const issuerOrigin = new URL(configuredIssuer).origin + const authorizationEndpoint = publicHttpsUrl(metadata.authorization_endpoint, issuerOrigin) + const tokenEndpoint = publicHttpsUrl(metadata.token_endpoint, issuerOrigin) + const jwksUrl = publicHttpsUrl(metadata.jwks_uri, issuerOrigin) + const signingAlgorithms = metadata.id_token_signing_alg_values_supported.filter((algorithm) => + allowedSigningAlgorithms.has(algorithm), + ) + if (signingAlgorithms.length === 0) { + throw new Error("OIDC discovery did not advertise a supported signing algorithm.") + } + cachedDiscovery = { + expiresAt: Date.now() + discoveryCacheMilliseconds, + issuer: configuredIssuer, + authorizationEndpoint, + tokenEndpoint, + signingAlgorithms, + jwks: createRemoteJWKSet(jwksUrl, { + cacheMaxAge: 10 * 60_000, + cooldownDuration: 5_000, + timeoutDuration: timeoutMilliseconds, + [customFetch]: request, + }), + } + return cachedDiscovery + } + + return { + async authorizationUrl(state, pkceVerifier, nonce, options) { + const metadata = await discovery() + const url = new URL(metadata.authorizationEndpoint) + url.search = new URLSearchParams({ + client_id: configuration.clientId, + redirect_uri: configuration.callbackUrl, + response_type: "code", + response_mode: "query", + scope: configuration.scopes.join(" "), + state, + nonce, + code_challenge: codeChallenge(pkceVerifier), + code_challenge_method: "S256", + }).toString() + if (options?.fresh) { + url.searchParams.set("prompt", "login") + url.searchParams.set("max_age", "0") + } + return url.href + }, + async identify(code, pkceVerifier, nonce, options): Promise { + const metadata = await discovery() + const token = tokenSchema.parse( + await fetchJson(metadata.tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: configuration.callbackUrl, + client_id: configuration.clientId, + client_secret: configuration.clientSecret, + code_verifier: pkceVerifier, + }), + }), + ) + const verified = await jwtVerify(token.id_token, metadata.jwks, { + issuer: metadata.issuer, + audience: configuration.clientId, + algorithms: metadata.signingAlgorithms, + clockTolerance: 5, + maxTokenAge: "10m", + }) + const claims = verified.payload + if (!claims.sub || typeof claims.exp !== "number" || claims.nonce !== nonce) { + throw new Error("OIDC token subject, expiration, or nonce is invalid.") + } + if ( + (claims.azp !== undefined && claims.azp !== configuration.clientId) || + (Array.isArray(claims.aud) && + claims.aud.length > 1 && + claims.azp !== configuration.clientId) + ) { + throw new Error("OIDC token authorized party is invalid.") + } + if (options?.authenticatedAfter) { + const authenticatedAt = + typeof claims.auth_time === "number" ? claims.auth_time * 1_000 : null + if ( + authenticatedAt === null || + authenticatedAt < options.authenticatedAfter.getTime() - 5_000 || + authenticatedAt > Date.now() + 5_000 + ) { + throw new Error("OIDC authentication is not fresh.") + } + } + const roles = Array.isArray(claims.roles) + ? claims.roles.filter((role): role is string => typeof role === "string") + : [] + const role = roles.includes(configuration.administratorRole) + ? "administrator" + : roles.includes(configuration.memberRole) + ? "member" + : null + return { + subject: claims.sub, + directoryObjectId: optionalUuid(claims.oid), + username: + optionalString(claims.preferred_username) ?? + optionalString(claims.email) ?? + optionalString(claims.upn), + email: optionalString(claims.email) ?? optionalString(claims.preferred_username), + displayName: optionalString(claims.name), + avatarUrl: optionalString(claims.picture), + role, + } + }, + } +} diff --git a/hub/server/integrations/oidc/create-client/types.ts b/hub/server/integrations/oidc/create-client/types.ts new file mode 100644 index 00000000..4676fa4d --- /dev/null +++ b/hub/server/integrations/oidc/create-client/types.ts @@ -0,0 +1,44 @@ +export interface OidcConfiguration { + issuer: string + clientId: string + clientSecret: string + clientCredentialReference: string + callbackUrl: string + scopes: string[] + memberRole: string + administratorRole: string + membershipPolicy: "jit-assignment" | "invitation-required" + enabled: boolean + providerRevision: number +} + +export interface OidcProfile { + subject: string + directoryObjectId?: string | null + username: string | null + email: string | null + displayName: string | null + avatarUrl: string | null + role: "member" | "administrator" | null +} + +export interface OidcClient { + authorizationUrl( + state: string, + pkceVerifier: string, + nonce: string, + options?: { fresh?: boolean }, + ): Promise + identify( + code: string, + pkceVerifier: string, + nonce: string, + options?: { authenticatedAfter?: Date }, + ): Promise +} + +export interface OidcClientOptions { + fetch?: typeof fetch + timeoutMilliseconds?: number + discoveryCacheMilliseconds?: number +} diff --git a/hub/server/integrations/pino/create-request-logger/index.test.ts b/hub/server/integrations/pino/create-request-logger/index.test.ts index 9c78c3a4..78f967e7 100644 --- a/hub/server/integrations/pino/create-request-logger/index.test.ts +++ b/hub/server/integrations/pino/create-request-logger/index.test.ts @@ -5,6 +5,7 @@ import express from "express" import { describe, expect, it, onTestFinished, vi } from "vitest" import { createLogger } from "../create-logger" +import { omitRequestLogPayload } from "../omit-request-log-payload" import { createRequestLogger } from "." @@ -111,7 +112,7 @@ describe("createRequestLogger()", () => { expect(records[0]).toMatchObject({ level: 50, statusCode: 500, outcome: "completed" }) }) - it("lets an endpoint omit all payload fields without suppressing completion metadata", async () => { + it("redacts normalized OAuth callback fields without changing the request", async () => { const records: Record[] = [] const logger = createLogger({ destination: { write: (line) => records.push(parseRecord(line)) }, @@ -119,23 +120,94 @@ describe("createRequestLogger()", () => { const app = express() app.use(createRequestLogger(logger)) app.use(express.json()) - app.post("/private/:id", (_req, res) => { - res.locals.omitLogPayload = true - res.json({ private: "response-secret" }) + app.post("/callback", (req, res) => { + const body: unknown = req.body + res.json({ query: req.query, body }) }) const baseUrl = await listen(app) + const query = new URLSearchParams({ + code: "query-code", + STATE: "query-state", + "no-nce": "query-nonce", + code_verifier: "query-verifier", + safe: "query-safe", + }) + const body = { + Code: "body-code", + state: "body-state", + nonce: "body-nonce", + "code.verifier": "body-verifier", + safe: "body-safe", + } - const response = await fetch(`${baseUrl}/private/param-secret?search=query-secret`, { + const response = await fetch(`${baseUrl}/callback?${query}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ text: "body-secret" }), + body: JSON.stringify(body), + }) + + expect(await response.json()).toEqual({ + query: { + code: "query-code", + STATE: "query-state", + "no-nce": "query-nonce", + code_verifier: "query-verifier", + safe: "query-safe", + }, + body, + }) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).toMatchObject({ + query: { + code: "[REDACTED]", + STATE: "[REDACTED]", + "no-nce": "[REDACTED]", + code_verifier: "[REDACTED]", + safe: "query-safe", + }, + body: { + Code: "[REDACTED]", + state: "[REDACTED]", + nonce: "[REDACTED]", + "code.verifier": "[REDACTED]", + safe: "body-safe", + }, + }) + expect(JSON.stringify(records)).not.toMatch( + /query-code|query-state|query-nonce|query-verifier|body-code|body-state|body-nonce|body-verifier/, + ) + }) + + it("lets an authentication router omit payload fields without suppressing completion metadata", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { write: (line) => records.push(parseRecord(line)) }, }) + const app = express() + app.use(createRequestLogger(logger)) + app.use(express.json()) + const authentication = express.Router() + authentication.use(omitRequestLogPayload) + authentication.post("/:provider/callback", (_req, res) => { + res.json({ private: "response-secret" }) + }) + app.use("/authentication", authentication) + const baseUrl = await listen(app) + + const response = await fetch( + `${baseUrl}/authentication/provider-secret/callback?search=query-secret`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "body-secret" }), + }, + ) await response.arrayBuffer() await vi.waitFor(() => expect(records).toHaveLength(1)) expect(records[0]).toMatchObject({ method: "POST", - route: "/private/:id", + route: "/:provider/callback", statusCode: 200, outcome: "completed", }) @@ -143,7 +215,7 @@ describe("createRequestLogger()", () => { expect(records[0]).not.toHaveProperty(field) } expect(JSON.stringify(records)).not.toMatch( - /param-secret|query-secret|body-secret|response-secret/, + /provider-secret|query-secret|body-secret|response-secret/, ) }) diff --git a/hub/server/integrations/pino/omit-request-log-payload/index.ts b/hub/server/integrations/pino/omit-request-log-payload/index.ts new file mode 100644 index 00000000..b9393eaa --- /dev/null +++ b/hub/server/integrations/pino/omit-request-log-payload/index.ts @@ -0,0 +1,7 @@ +import type { NextFunction, Request, Response } from "express" + +/** Exclude authentication request payloads while retaining completion metadata. */ +export function omitRequestLogPayload(_request: Request, response: Response, next: NextFunction) { + response.locals.omitLogPayload = true + next() +} diff --git a/hub/server/integrations/pino/snapshot-request/index.ts b/hub/server/integrations/pino/snapshot-request/index.ts index 8a1b9d15..6f2daf2c 100644 --- a/hub/server/integrations/pino/snapshot-request/index.ts +++ b/hub/server/integrations/pino/snapshot-request/index.ts @@ -1,5 +1,6 @@ import type { Request } from "express" +import { sensitiveRequestLogKeyPattern } from "../../../utils/logging/sanitize-log-value/constants.ts" import { sanitizeLogValue } from "../../../utils/logging/sanitize-log-value/index.ts" import type { RequestSnapshot } from "./types.ts" @@ -11,10 +12,10 @@ export function snapshotRequest(request: Request): RequestSnapshot { route !== null && typeof route === "object" && "path" in route ? route.path : undefined return { route: typeof path === "string" ? path : null, - params: sanitizeLogValue(request.params), - query: sanitizeLogValue(request.query), + params: sanitizeLogValue(request.params, sensitiveRequestLogKeyPattern), + query: sanitizeLogValue(request.query, sensitiveRequestLogKeyPattern), ...(request.is("application/json") && request.body !== undefined - ? { body: sanitizeLogValue(request.body) } + ? { body: sanitizeLogValue(request.body, sensitiveRequestLogKeyPattern) } : {}), } } diff --git a/hub/server/integrations/prisma/index.ts b/hub/server/integrations/prisma/index.ts index f9c0d9b0..78f43a6d 100644 --- a/hub/server/integrations/prisma/index.ts +++ b/hub/server/integrations/prisma/index.ts @@ -1,5 +1,5 @@ -import "dotenv/config" import { PrismaPg } from "@prisma/adapter-pg" +import "dotenv/config" import { PrismaClient } from "../../generated/prisma/client.ts" diff --git a/hub/server/repositories/authentication-providers/create-provider-revision/index.ts b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts new file mode 100644 index 00000000..23cc1a54 --- /dev/null +++ b/hub/server/repositories/authentication-providers/create-provider-revision/index.ts @@ -0,0 +1,33 @@ +import type { AuthenticationProviderInput } from "#server/repositories/authentication-providers/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createAuthenticationProviderRevision( + database: Prisma.TransactionClient, + input: AuthenticationProviderInput, +) { + const existing = await database.authenticationProvider.findUnique({ + where: { key: input.key }, + select: { type: true }, + }) + if (existing && existing.type !== input.type) { + throw new Error("Authentication provider type cannot change.") + } + const provider = await database.authenticationProvider.upsert({ + where: { key: input.key }, + create: { key: input.key, type: input.type, enabled: input.enabled }, + update: { enabled: input.enabled }, + }) + if (provider.type !== input.type) { + throw new Error("Authentication provider type cannot change.") + } + return database.authenticationProviderRevision.create({ + data: { + authenticationProviderId: provider.id, + revision: input.revision, + issuer: input.issuer, + configuration: input.configuration, + }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authentication-providers/get-current-revision/index.ts b/hub/server/repositories/authentication-providers/get-current-revision/index.ts new file mode 100644 index 00000000..6d202814 --- /dev/null +++ b/hub/server/repositories/authentication-providers/get-current-revision/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getCurrentAuthenticationProviderRevision( + database: Prisma.TransactionClient, + key: string, +) { + return database.authenticationProviderRevision.findFirst({ + where: { authenticationProvider: { key, enabled: true } }, + orderBy: { revision: "desc" }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authentication-providers/get-latest-revision/index.ts b/hub/server/repositories/authentication-providers/get-latest-revision/index.ts new file mode 100644 index 00000000..3e4a73dd --- /dev/null +++ b/hub/server/repositories/authentication-providers/get-latest-revision/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getLatestAuthenticationProviderRevision( + database: Prisma.TransactionClient, + key: string, +) { + return database.authenticationProviderRevision.findFirst({ + where: { authenticationProvider: { key } }, + orderBy: { revision: "desc" }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authentication-providers/get-revision/index.ts b/hub/server/repositories/authentication-providers/get-revision/index.ts new file mode 100644 index 00000000..6abb080a --- /dev/null +++ b/hub/server/repositories/authentication-providers/get-revision/index.ts @@ -0,0 +1,8 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getAuthenticationProviderRevision(database: Prisma.TransactionClient, id: string) { + return database.authenticationProviderRevision.findUnique({ + where: { id }, + include: { authenticationProvider: true }, + }) +} diff --git a/hub/server/repositories/authentication-providers/types.ts b/hub/server/repositories/authentication-providers/types.ts new file mode 100644 index 00000000..8c320138 --- /dev/null +++ b/hub/server/repositories/authentication-providers/types.ts @@ -0,0 +1,10 @@ +import type { Prisma } from "../../generated/prisma/client.ts" + +export interface AuthenticationProviderInput { + key: string + type: "github" | "oidc" + enabled: boolean + revision: number + issuer: string + configuration: Prisma.InputJsonValue +} diff --git a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts new file mode 100644 index 00000000..95123735 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -0,0 +1,52 @@ +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +interface ConsumedAuthorizationAttempt extends AuthorizationAttempt { + valid: boolean +} + +export async function consumeAuthorizationAttempt( + database: Prisma.TransactionClient, + stateDigest: string, +): Promise { + const [attempt] = await database.$queryRaw` + DELETE FROM authorization_attempts + WHERE state_digest = ${stateDigest} + RETURNING + id, + authentication_provider_revision_id AS "authenticationProviderRevisionId", + originating_session_id AS "originatingSessionId", + state_digest AS "stateDigest", + browser_digest AS "browserDigest", + pkce_verifier AS "pkceVerifier", + nonce, + purpose, + session_generation AS "sessionGeneration", + expires_at AS "expiresAt", + expires_at > clock_timestamp() AS valid + ` + if (!attempt) return null + if (!attempt.valid) return null + if ( + attempt.purpose !== "sign-in" && + attempt.purpose !== "verify" && + attempt.purpose !== "link-identity" && + attempt.purpose !== "bootstrap" && + attempt.purpose !== "recovery" + ) { + throw new Error("Stored authorization purpose is invalid.") + } + return { + id: attempt.id, + authenticationProviderRevisionId: attempt.authenticationProviderRevisionId, + originatingSessionId: attempt.originatingSessionId, + stateDigest: attempt.stateDigest, + browserDigest: attempt.browserDigest, + pkceVerifier: attempt.pkceVerifier, + nonce: attempt.nonce, + purpose: attempt.purpose, + sessionGeneration: attempt.sessionGeneration, + expiresAt: attempt.expiresAt, + } +} diff --git a/hub/server/repositories/authorization-attempts/create-attempt/index.ts b/hub/server/repositories/authorization-attempts/create-attempt/index.ts new file mode 100644 index 00000000..cab6fc09 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/create-attempt/index.ts @@ -0,0 +1,13 @@ +import type { AuthorizationAttempt } from "#server/repositories/authorization-attempts/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createAuthorizationAttempt( + database: Prisma.TransactionClient, + data: Omit & { + sessionGeneration?: number + }, +) { + await database.authorizationAttempt.deleteMany({ where: { expiresAt: { lte: new Date() } } }) + await database.authorizationAttempt.create({ data }) +} diff --git a/hub/server/repositories/authorization-attempts/types.ts b/hub/server/repositories/authorization-attempts/types.ts new file mode 100644 index 00000000..9bad2393 --- /dev/null +++ b/hub/server/repositories/authorization-attempts/types.ts @@ -0,0 +1,12 @@ +export interface AuthorizationAttempt { + id: string + authenticationProviderRevisionId: string + originatingSessionId: string | null + stateDigest: string + browserDigest: string + pkceVerifier: string + nonce: string | null + purpose: "sign-in" | "verify" | "link-identity" | "bootstrap" | "recovery" + sessionGeneration: number + expiresAt: Date +} diff --git a/hub/server/repositories/external-identities/get-external-identity-by-directory-object-id/index.ts b/hub/server/repositories/external-identities/get-external-identity-by-directory-object-id/index.ts new file mode 100644 index 00000000..fc9ddca6 --- /dev/null +++ b/hub/server/repositories/external-identities/get-external-identity-by-directory-object-id/index.ts @@ -0,0 +1,15 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getExternalIdentityByDirectoryObjectId( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + directoryObjectId: string, +) { + return database.externalIdentity.findUnique({ + where: { + provider_issuer_directoryObjectId: { provider, issuer, directoryObjectId }, + }, + include: { user: true }, + }) +} diff --git a/hub/server/repositories/external-identities/get-external-identity/index.ts b/hub/server/repositories/external-identities/get-external-identity/index.ts new file mode 100644 index 00000000..126c6da0 --- /dev/null +++ b/hub/server/repositories/external-identities/get-external-identity/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getExternalIdentity( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + subject: string, +) { + return database.externalIdentity.findUnique({ + where: { provider_issuer_subject: { provider, issuer, subject } }, + include: { user: true }, + }) +} diff --git a/hub/server/repositories/external-identities/save-external-identity/index.ts b/hub/server/repositories/external-identities/save-external-identity/index.ts new file mode 100644 index 00000000..d9f4f29c --- /dev/null +++ b/hub/server/repositories/external-identities/save-external-identity/index.ts @@ -0,0 +1,22 @@ +import type { ExternalIdentityInput } from "#server/repositories/external-identities/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function saveExternalIdentity( + database: Prisma.TransactionClient, + identity: ExternalIdentityInput, +) { + const { provider, issuer, subject } = identity + return database.externalIdentity.upsert({ + where: { provider_issuer_subject: { provider, issuer, subject } }, + create: identity, + update: { + directoryObjectId: identity.directoryObjectId ?? null, + username: identity.username, + email: identity.email ?? null, + displayName: identity.displayName, + avatarUrl: identity.avatarUrl, + }, + include: { user: true }, + }) +} diff --git a/hub/server/repositories/external-identities/types.ts b/hub/server/repositories/external-identities/types.ts new file mode 100644 index 00000000..53d7f703 --- /dev/null +++ b/hub/server/repositories/external-identities/types.ts @@ -0,0 +1,17 @@ +export interface ExternalIdentityInput { + userId: string + provider: string + issuer: string + subject: string + directoryObjectId?: string | null + username: string | null + email?: string | null + displayName: string | null + avatarUrl: string | null +} + +export interface HubExternalIdentity extends ExternalIdentityInput { + id: string + directoryObjectId: string | null + email: string | null +} diff --git a/hub/server/repositories/external-identities/update-external-identity-subject/index.ts b/hub/server/repositories/external-identities/update-external-identity-subject/index.ts new file mode 100644 index 00000000..7dbc57e1 --- /dev/null +++ b/hub/server/repositories/external-identities/update-external-identity-subject/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function updateExternalIdentitySubject( + database: Prisma.TransactionClient, + id: string, + subject: string, +) { + return database.externalIdentity.update({ + where: { id }, + data: { subject }, + include: { user: true }, + }) +} diff --git a/hub/server/repositories/github-integration-state/advance-generation/index.ts b/hub/server/repositories/github-integration-state/advance-generation/index.ts new file mode 100644 index 00000000..92420a95 --- /dev/null +++ b/hub/server/repositories/github-integration-state/advance-generation/index.ts @@ -0,0 +1,11 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function advanceGitHubIntegrationGeneration(database: Prisma.TransactionClient) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId, generation: 1 }, + update: { generation: { increment: 1 } }, + }) +} diff --git a/hub/server/repositories/github-integration-state/constants.ts b/hub/server/repositories/github-integration-state/constants.ts new file mode 100644 index 00000000..b86d5af4 --- /dev/null +++ b/hub/server/repositories/github-integration-state/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process must lock the same row before changing GitHub integration credentials. +export const staticGitHubIntegrationRecordId = "00000000-0000-4000-8000-000000000001" diff --git a/hub/server/repositories/github-integration-state/ensure-state/index.ts b/hub/server/repositories/github-integration-state/ensure-state/index.ts new file mode 100644 index 00000000..c3a1116f --- /dev/null +++ b/hub/server/repositories/github-integration-state/ensure-state/index.ts @@ -0,0 +1,11 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function ensureGitHubIntegrationState(database: Prisma.TransactionClient) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId }, + update: {}, + }) +} diff --git a/hub/server/repositories/github-integration-state/get-state/index.ts b/hub/server/repositories/github-integration-state/get-state/index.ts new file mode 100644 index 00000000..1107a6cf --- /dev/null +++ b/hub/server/repositories/github-integration-state/get-state/index.ts @@ -0,0 +1,9 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getGitHubIntegrationState(database: Prisma.TransactionClient) { + return database.gitHubIntegrationState.findUniqueOrThrow({ + where: { id: staticGitHubIntegrationRecordId }, + }) +} diff --git a/hub/server/repositories/github-integration-state/save-credentials/index.ts b/hub/server/repositories/github-integration-state/save-credentials/index.ts new file mode 100644 index 00000000..e053f527 --- /dev/null +++ b/hub/server/repositories/github-integration-state/save-credentials/index.ts @@ -0,0 +1,14 @@ +import { staticGitHubIntegrationRecordId } from "#server/repositories/github-integration-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveGitHubIntegrationCredentials( + database: Prisma.TransactionClient, + encryptedCredentials: string, +) { + await database.gitHubIntegrationState.upsert({ + where: { id: staticGitHubIntegrationRecordId }, + create: { id: staticGitHubIntegrationRecordId, encryptedCredentials }, + update: { encryptedCredentials }, + }) +} diff --git a/hub/server/repositories/github-integration-state/types.ts b/hub/server/repositories/github-integration-state/types.ts new file mode 100644 index 00000000..b26f045a --- /dev/null +++ b/hub/server/repositories/github-integration-state/types.ts @@ -0,0 +1,4 @@ +export interface GitHubIntegrationState { + generation: number + encryptedCredentials: string | null +} diff --git a/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts b/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts new file mode 100644 index 00000000..122fc2c5 --- /dev/null +++ b/hub/server/repositories/github-synchronized-access/has-fresh-admission/index.ts @@ -0,0 +1,27 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +const policyId = "00000000-0000-0000-0000-000000000001" + +export async function hasFreshGitHubAdmission( + database: Prisma.TransactionClient, + userId: string, + now: Date, +): Promise { + const policy = await database.gitHubAuthorizationPolicy.findUnique({ + where: { id: policyId }, + select: { lastKnownGoodSeconds: true }, + }) + const cutoff = new Date(now.getTime() - (policy?.lastKnownGoodSeconds ?? 86_400) * 1_000) + return ( + (await database.gitHubSynchronizedAccess.findFirst({ + where: { + userId, + status: "active", + admissionEligible: true, + lastConfirmedAt: { gte: cutoff }, + repositorySource: { status: "active", admissionEnabled: true }, + }, + select: { id: true }, + })) !== null + ) +} diff --git a/hub/server/repositories/identity-store-state/advance-session-generation/index.ts b/hub/server/repositories/identity-store-state/advance-session-generation/index.ts new file mode 100644 index 00000000..c4be0fba --- /dev/null +++ b/hub/server/repositories/identity-store-state/advance-session-generation/index.ts @@ -0,0 +1,10 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function advanceSessionGeneration(database: Prisma.TransactionClient) { + await database.identityStoreState.update({ + where: { id: staticIdentityStoreRecordId }, + data: { sessionGeneration: { increment: 1 } }, + }) +} diff --git a/hub/server/repositories/identity-store-state/complete-bootstrap/index.ts b/hub/server/repositories/identity-store-state/complete-bootstrap/index.ts new file mode 100644 index 00000000..c8a1a35e --- /dev/null +++ b/hub/server/repositories/identity-store-state/complete-bootstrap/index.ts @@ -0,0 +1,14 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +import { staticIdentityStoreRecordId } from "../constants.ts" + +export async function completeBootstrap( + database: Prisma.TransactionClient, + completedAt: Date, +): Promise { + const updated = await database.identityStoreState.updateMany({ + where: { id: staticIdentityStoreRecordId, bootstrapCompletedAt: null }, + data: { bootstrapCompletedAt: completedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/identity-store-state/constants.ts b/hub/server/repositories/identity-store-state/constants.ts new file mode 100644 index 00000000..3d42c96e --- /dev/null +++ b/hub/server/repositories/identity-store-state/constants.ts @@ -0,0 +1,2 @@ +// Every Hub process must lock the same row before changing identity persistence. +export const staticIdentityStoreRecordId = "00000000-0000-4000-8000-000000000002" diff --git a/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts b/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts new file mode 100644 index 00000000..eae2e392 --- /dev/null +++ b/hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts @@ -0,0 +1,11 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getBootstrapCompletedAt(database: Prisma.TransactionClient) { + const state = await database.identityStoreState.findUniqueOrThrow({ + where: { id: staticIdentityStoreRecordId }, + select: { bootstrapCompletedAt: true }, + }) + return state.bootstrapCompletedAt +} diff --git a/hub/server/repositories/identity-store-state/get-state/index.ts b/hub/server/repositories/identity-store-state/get-state/index.ts new file mode 100644 index 00000000..2633c9ca --- /dev/null +++ b/hub/server/repositories/identity-store-state/get-state/index.ts @@ -0,0 +1,10 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getIdentityStoreState(database: Prisma.TransactionClient) { + return database.identityStoreState.findUniqueOrThrow({ + where: { id: staticIdentityStoreRecordId }, + select: { sessionGeneration: true }, + }) +} diff --git a/hub/server/repositories/identity-store-state/types.ts b/hub/server/repositories/identity-store-state/types.ts new file mode 100644 index 00000000..fb896ced --- /dev/null +++ b/hub/server/repositories/identity-store-state/types.ts @@ -0,0 +1,3 @@ +export interface IdentityStoreState { + sessionGeneration: number +} diff --git a/hub/server/repositories/identity-store-state/with-store-lock/index.ts b/hub/server/repositories/identity-store-state/with-store-lock/index.ts new file mode 100644 index 00000000..f8b8b247 --- /dev/null +++ b/hub/server/repositories/identity-store-state/with-store-lock/index.ts @@ -0,0 +1,18 @@ +import { staticIdentityStoreRecordId } from "#server/repositories/identity-store-state/constants" + +import type { Prisma, PrismaClient } from "../../../generated/prisma/client.ts" + +export function withIdentityStoreLock( + client: PrismaClient, + operation: (database: Prisma.TransactionClient) => Promise, +): Promise { + return client.$transaction(async (transaction) => { + await transaction.identityStoreState.upsert({ + where: { id: staticIdentityStoreRecordId }, + create: { id: staticIdentityStoreRecordId }, + update: {}, + }) + await transaction.$queryRaw`SELECT id FROM identity_store_states WHERE id = ${staticIdentityStoreRecordId}::uuid FOR UPDATE` + return operation(transaction) + }) +} diff --git a/hub/server/repositories/installation-memberships/count-active-administrators/index.ts b/hub/server/repositories/installation-memberships/count-active-administrators/index.ts new file mode 100644 index 00000000..877e61a5 --- /dev/null +++ b/hub/server/repositories/installation-memberships/count-active-administrators/index.ts @@ -0,0 +1,7 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function countActiveAdministrators(database: Prisma.TransactionClient) { + return database.installationMembership.count({ + where: { status: "active", role: "administrator" }, + }) +} diff --git a/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts b/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts new file mode 100644 index 00000000..82885058 --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-membership-by-id/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipDetails } from "../types.ts" + +export async function getInstallationMembershipById( + database: Prisma.TransactionClient, + id: string, +): Promise { + return (await database.installationMembership.findUnique({ + where: { id }, + include: { user: { include: { externalIdentities: true } } }, + })) as InstallationMembershipDetails | null +} diff --git a/hub/server/repositories/installation-memberships/get-membership/index.ts b/hub/server/repositories/installation-memberships/get-membership/index.ts new file mode 100644 index 00000000..f79ddec6 --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-membership/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipRecord } from "../types.ts" + +export async function getInstallationMembership( + database: Prisma.TransactionClient, + userId: string, +): Promise { + return (await database.installationMembership.findUnique({ + where: { userId }, + })) as InstallationMembershipRecord | null +} diff --git a/hub/server/repositories/installation-memberships/get-memberships/index.ts b/hub/server/repositories/installation-memberships/get-memberships/index.ts new file mode 100644 index 00000000..7d3580ae --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-memberships/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipDetails } from "../types.ts" + +export async function getInstallationMemberships( + database: Prisma.TransactionClient, +): Promise { + return (await database.installationMembership.findMany({ + include: { user: { include: { externalIdentities: true } } }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 100, + })) as InstallationMembershipDetails[] +} diff --git a/hub/server/repositories/installation-memberships/save-membership/index.ts b/hub/server/repositories/installation-memberships/save-membership/index.ts new file mode 100644 index 00000000..930b75ae --- /dev/null +++ b/hub/server/repositories/installation-memberships/save-membership/index.ts @@ -0,0 +1,18 @@ +import type { + InstallationMembershipInput, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveInstallationMembership( + database: Prisma.TransactionClient, + input: InstallationMembershipInput, +): Promise { + const { userId, ...membership } = input + return (await database.installationMembership.upsert({ + where: { userId }, + create: input, + update: membership, + })) as InstallationMembershipRecord +} diff --git a/hub/server/repositories/installation-memberships/types.ts b/hub/server/repositories/installation-memberships/types.ts new file mode 100644 index 00000000..18269e33 --- /dev/null +++ b/hub/server/repositories/installation-memberships/types.ts @@ -0,0 +1,31 @@ +export interface InstallationMembershipInput { + userId: string + status: "active" | "suspended" | "removed" + role: "member" | "administrator" + source: + | "bootstrap" + | "invitation" + | "legacy-synchronized" + | "manual" + | "oidc-assignment" + | "recovery" + | "synchronized" + suspendedAt?: Date | null + removedAt?: Date | null +} + +export interface InstallationMembershipRecord { + id: string + userId: string + status: InstallationMembershipInput["status"] + role: InstallationMembershipInput["role"] + source: InstallationMembershipInput["source"] + suspendedAt: Date | null + removedAt: Date | null +} + +export interface InstallationMembershipDetails extends InstallationMembershipRecord { + user: HubUser & { externalIdentities: HubExternalIdentity[] } +} +import type { HubExternalIdentity } from "#server/repositories/external-identities/types" +import type { HubUser } from "#server/repositories/users/types" diff --git a/hub/server/repositories/membership-invitations/consume-invitation/index.ts b/hub/server/repositories/membership-invitations/consume-invitation/index.ts new file mode 100644 index 00000000..ba1b2194 --- /dev/null +++ b/hub/server/repositories/membership-invitations/consume-invitation/index.ts @@ -0,0 +1,15 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function consumeMembershipInvitation( + database: Prisma.TransactionClient, + id: string, + activatedIdentityId: string, + activatedMembershipId: string, + consumedAt: Date, +): Promise { + const updated = await database.membershipInvitation.updateMany({ + where: { id, consumedAt: null, revokedAt: null, expiresAt: { gt: consumedAt } }, + data: { activatedIdentityId, activatedMembershipId, consumedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/membership-invitations/create-invitation/index.ts b/hub/server/repositories/membership-invitations/create-invitation/index.ts new file mode 100644 index 00000000..dd721b6b --- /dev/null +++ b/hub/server/repositories/membership-invitations/create-invitation/index.ts @@ -0,0 +1,15 @@ +import type { + MembershipInvitationInput, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createMembershipInvitation( + database: Prisma.TransactionClient, + input: MembershipInvitationInput, +): Promise { + return (await database.membershipInvitation.create({ + data: input, + })) as MembershipInvitationRecord +} diff --git a/hub/server/repositories/membership-invitations/get-invitation/index.ts b/hub/server/repositories/membership-invitations/get-invitation/index.ts new file mode 100644 index 00000000..d4d3d130 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitation/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitation( + database: Prisma.TransactionClient, + id: string, +): Promise { + return (await database.membershipInvitation.findUnique({ + where: { id }, + })) as MembershipInvitationRecord | null +} diff --git a/hub/server/repositories/membership-invitations/get-invitations/index.ts b/hub/server/repositories/membership-invitations/get-invitations/index.ts new file mode 100644 index 00000000..e835e410 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitations/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitations( + database: Prisma.TransactionClient, +): Promise { + return (await database.membershipInvitation.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 100, + })) as MembershipInvitationRecord[] +} diff --git a/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts b/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts new file mode 100644 index 00000000..9c7dfed6 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts @@ -0,0 +1,22 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getUsableMembershipInvitation( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + subject: string, + now: Date, +): Promise { + return (await database.membershipInvitation.findFirst({ + where: { + provider, + issuer, + subject, + consumedAt: null, + revokedAt: null, + expiresAt: { gt: now }, + }, + orderBy: { createdAt: "asc" }, + })) as MembershipInvitationRecord | null +} diff --git a/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts b/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts new file mode 100644 index 00000000..ba501bfc --- /dev/null +++ b/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function revokeExpiredMembershipInvitations( + database: Prisma.TransactionClient, + revokedAt: Date, +) { + await database.membershipInvitation.updateMany({ + where: { consumedAt: null, revokedAt: null, expiresAt: { lte: revokedAt } }, + data: { revokedAt }, + }) +} diff --git a/hub/server/repositories/membership-invitations/revoke-invitation/index.ts b/hub/server/repositories/membership-invitations/revoke-invitation/index.ts new file mode 100644 index 00000000..563e8665 --- /dev/null +++ b/hub/server/repositories/membership-invitations/revoke-invitation/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function revokeMembershipInvitation( + database: Prisma.TransactionClient, + id: string, + revokedAt: Date, +): Promise { + const updated = await database.membershipInvitation.updateMany({ + where: { id, consumedAt: null, revokedAt: null }, + data: { revokedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/membership-invitations/types.ts b/hub/server/repositories/membership-invitations/types.ts new file mode 100644 index 00000000..86401ab7 --- /dev/null +++ b/hub/server/repositories/membership-invitations/types.ts @@ -0,0 +1,17 @@ +export interface MembershipInvitationInput { + createdByUserId: string | null + provider: string + issuer: string + subject: string + role: "member" | "administrator" + expiresAt: Date +} + +export interface MembershipInvitationRecord extends MembershipInvitationInput { + id: string + activatedIdentityId: string | null + activatedMembershipId: string | null + consumedAt: Date | null + revokedAt: Date | null + createdAt: Date +} diff --git a/hub/server/repositories/security-audits/constants.ts b/hub/server/repositories/security-audits/constants.ts new file mode 100644 index 00000000..2f80cb2e --- /dev/null +++ b/hub/server/repositories/security-audits/constants.ts @@ -0,0 +1,2 @@ +export const auditRetentionMs = 90 * 24 * 60 * 60_000 +export const auditResultLimit = 100 diff --git a/hub/server/repositories/security-audits/get-audits/index.ts b/hub/server/repositories/security-audits/get-audits/index.ts new file mode 100644 index 00000000..6783bc47 --- /dev/null +++ b/hub/server/repositories/security-audits/get-audits/index.ts @@ -0,0 +1,14 @@ +import { auditResultLimit, auditRetentionMs } from "#server/repositories/security-audits/constants" +import type { SecurityAuditRecord } from "#server/repositories/security-audits/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function getSecurityAudits( + database: Prisma.TransactionClient, +): Promise { + return database.securityAudit.findMany({ + where: { createdAt: { gte: new Date(Date.now() - auditRetentionMs) } }, + orderBy: { createdAt: "desc" }, + take: auditResultLimit, + }) +} diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts new file mode 100644 index 00000000..4ed58250 --- /dev/null +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -0,0 +1,37 @@ +import { auditRetentionMs } from "#server/repositories/security-audits/constants" +import type { AuditInput } from "#server/repositories/security-audits/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function saveSecurityAudit(database: Prisma.TransactionClient, data: AuditInput) { + const context = data.context ?? {} + const validContext = + Object.keys(context).every((key) => key === "reason" || key === "source") && + Object.values(context).every((value) => /^[a-z][a-z0-9_]{0,63}$/.test(value)) + if ( + !/^[a-z][a-z0-9_.]{0,79}$/.test(data.event) || + !["succeeded", "denied", "failed"].includes(data.outcome ?? "succeeded") || + !validContext + ) { + throw new Error("Security audit input is invalid.") + } + await database.securityAudit.deleteMany({ + where: { createdAt: { lt: new Date(Date.now() - auditRetentionMs) } }, + }) + await database.securityAudit.create({ + data: { + ...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, + workloadPublisherId: data.workloadPublisherId ?? null, + workloadCredentialId: data.workloadCredentialId ?? null, + targetUserId: data.targetUserId ?? null, + githubRepositorySourceId: data.githubRepositorySourceId ?? null, + context, + }, + }) +} diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts new file mode 100644 index 00000000..55bbcb22 --- /dev/null +++ b/hub/server/repositories/security-audits/types.ts @@ -0,0 +1,37 @@ +export interface AuditInput { + event: string + outcome?: "succeeded" | "denied" | "failed" + actorUserId: string | null + sessionId: string | null + authenticationProviderRevisionId: string | null + targetMembershipId?: string | null + invitationId?: string | null + workspaceId?: string | null + prototypeId?: string | null + prototypeVersionId?: string | null + workloadPublisherId?: string | null + workloadCredentialId?: string | null + targetUserId?: string | null + githubRepositorySourceId?: string | null + context?: Record +} + +export interface SecurityAuditRecord { + id: string + event: string + outcome: string + actorUserId: string | null + sessionId: string | null + authenticationProviderRevisionId: string | null + targetMembershipId: string | null + invitationId: string | null + workspaceId: string | null + prototypeId: string | null + prototypeVersionId: string | null + workloadPublisherId: string | null + workloadCredentialId: string | null + targetUserId: string | null + githubRepositorySourceId: string | null + context: unknown + createdAt: Date +} diff --git a/hub/server/repositories/sessions/create-session/index.ts b/hub/server/repositories/sessions/create-session/index.ts new file mode 100644 index 00000000..8a8e8a5d --- /dev/null +++ b/hub/server/repositories/sessions/create-session/index.ts @@ -0,0 +1,32 @@ +import type { HubSession } from "#server/repositories/sessions/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createSession( + database: Prisma.TransactionClient, + data: Omit, +) { + const [identity, providerRevision] = await Promise.all([ + database.externalIdentity.findUnique({ + where: { id: data.externalIdentityId }, + select: { userId: true, provider: true, issuer: true }, + }), + database.authenticationProviderRevision.findUnique({ + where: { id: data.authenticationProviderRevisionId }, + select: { issuer: true, authenticationProvider: { select: { type: true } } }, + }), + ]) + if (identity?.userId !== data.userId) { + throw new Error("Session external identity must belong to the Hub user.") + } + if (!providerRevision) { + throw new Error("Session authentication provider revision does not exist.") + } + if (identity.provider !== providerRevision.authenticationProvider.type) { + throw new Error("Session external identity provider must match the authentication provider.") + } + if (identity.issuer !== providerRevision.issuer) { + throw new Error("Session external identity issuer must match the provider revision.") + } + return database.session.create({ data }) +} diff --git a/hub/server/repositories/sessions/delete-session/index.ts b/hub/server/repositories/sessions/delete-session/index.ts new file mode 100644 index 00000000..439d061e --- /dev/null +++ b/hub/server/repositories/sessions/delete-session/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSession(database: Prisma.TransactionClient, id: string) { + await database.session.deleteMany({ where: { id } }) +} diff --git a/hub/server/repositories/sessions/delete-sessions/index.ts b/hub/server/repositories/sessions/delete-sessions/index.ts new file mode 100644 index 00000000..2d8f5fd1 --- /dev/null +++ b/hub/server/repositories/sessions/delete-sessions/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteSessions(database: Prisma.TransactionClient) { + await database.session.deleteMany() +} diff --git a/hub/server/repositories/sessions/delete-user-sessions/index.ts b/hub/server/repositories/sessions/delete-user-sessions/index.ts new file mode 100644 index 00000000..81870fda --- /dev/null +++ b/hub/server/repositories/sessions/delete-user-sessions/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function deleteUserSessions(database: Prisma.TransactionClient, userId: string) { + await database.session.deleteMany({ where: { userId } }) +} diff --git a/hub/server/repositories/sessions/get-session-by-id/index.ts b/hub/server/repositories/sessions/get-session-by-id/index.ts new file mode 100644 index 00000000..4d7e4df6 --- /dev/null +++ b/hub/server/repositories/sessions/get-session-by-id/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSessionById(database: Prisma.TransactionClient, id: string) { + return database.session.findUnique({ + where: { id }, + include: { + user: true, + externalIdentity: true, + authenticationProviderRevision: { include: { authenticationProvider: true } }, + }, + }) +} diff --git a/hub/server/repositories/sessions/get-session/index.ts b/hub/server/repositories/sessions/get-session/index.ts new file mode 100644 index 00000000..746b6750 --- /dev/null +++ b/hub/server/repositories/sessions/get-session/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getSession(database: Prisma.TransactionClient, tokenDigest: string) { + return database.session.findUnique({ + where: { tokenDigest }, + include: { + user: true, + externalIdentity: true, + authenticationProviderRevision: { include: { authenticationProvider: true } }, + }, + }) +} diff --git a/hub/server/repositories/sessions/invalidate-freshness/index.ts b/hub/server/repositories/sessions/invalidate-freshness/index.ts new file mode 100644 index 00000000..9b106204 --- /dev/null +++ b/hub/server/repositories/sessions/invalidate-freshness/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function invalidateFreshness(database: Prisma.TransactionClient) { + await database.session.updateMany({ data: { freshAuthorizedAt: null } }) +} diff --git a/hub/server/repositories/sessions/types.ts b/hub/server/repositories/sessions/types.ts new file mode 100644 index 00000000..f49defe5 --- /dev/null +++ b/hub/server/repositories/sessions/types.ts @@ -0,0 +1,9 @@ +export interface HubSession { + id: string + userId: string + externalIdentityId: string + authenticationProviderRevisionId: string + tokenDigest: string + signedInAt: Date + freshAuthorizedAt: Date | null +} diff --git a/hub/server/repositories/sessions/verify-session/index.ts b/hub/server/repositories/sessions/verify-session/index.ts new file mode 100644 index 00000000..2b278df8 --- /dev/null +++ b/hub/server/repositories/sessions/verify-session/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function verifySession( + database: Prisma.TransactionClient, + id: string, + freshAuthorizedAt: Date, +) { + await database.session.update({ + where: { id }, + data: { freshAuthorizedAt }, + }) +} diff --git a/hub/server/repositories/users/create-user/index.ts b/hub/server/repositories/users/create-user/index.ts new file mode 100644 index 00000000..3282981a --- /dev/null +++ b/hub/server/repositories/users/create-user/index.ts @@ -0,0 +1,7 @@ +import type { UserInput } from "#server/repositories/users/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function createUser(database: Prisma.TransactionClient, input: UserInput) { + return database.user.create({ data: input }) +} diff --git a/hub/server/repositories/users/get-user/index.ts b/hub/server/repositories/users/get-user/index.ts new file mode 100644 index 00000000..956bc0e6 --- /dev/null +++ b/hub/server/repositories/users/get-user/index.ts @@ -0,0 +1,5 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function getUser(database: Prisma.TransactionClient, id: string) { + return database.user.findUnique({ where: { id } }) +} diff --git a/hub/server/repositories/users/types.ts b/hub/server/repositories/users/types.ts new file mode 100644 index 00000000..50eb5c67 --- /dev/null +++ b/hub/server/repositories/users/types.ts @@ -0,0 +1,7 @@ +export interface HubUser { + id: string + displayName: string | null + avatarUrl: string | null +} + +export type UserInput = Omit diff --git a/hub/server/repositories/users/update-user/index.ts b/hub/server/repositories/users/update-user/index.ts new file mode 100644 index 00000000..81b5e698 --- /dev/null +++ b/hub/server/repositories/users/update-user/index.ts @@ -0,0 +1,7 @@ +import type { UserInput } from "#server/repositories/users/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export function updateUser(database: Prisma.TransactionClient, id: string, input: UserInput) { + return database.user.update({ where: { id }, data: input }) +} diff --git a/hub/server/routes/administration.test.ts b/hub/server/routes/administration.test.ts new file mode 100644 index 00000000..8b45b386 --- /dev/null +++ b/hub/server/routes/administration.test.ts @@ -0,0 +1,184 @@ +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 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 & { administration: Mocked } { + const service = { + start: vi.fn(), + complete: vi.fn(), + current: vi.fn(), + signOut: vi.fn(), + revokeAll: vi.fn(), + } as unknown as Mocked + const administration = Object.assign( + { + list: vi.fn().mockResolvedValue({ memberships: [], invitations: [] }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + }, + overrides, + ) + return { + service, + administration, + prototypeAuthorization: {} as PrototypeAuthorizationService, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes) { + const server = createServer(createApp({ authentication })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe("administration routes", () => { + it("returns an explicit authorization failure without leaking the session token", async () => { + const authentication = runtime({ + list: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ), + ), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: "administrator_required", + message: "Administrator access is required.", + }) + expect(authentication.administration.list.mock.calls).toEqual([[sessionToken]]) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }) + + it.each([ + { name: "missing", origin: undefined }, + { name: "wrong", origin: "https://attacker.example" }, + ])("rejects a $name Origin before an administration mutation runs", async ({ origin }) => { + const authentication = runtime() + const baseUrl = await serve(authentication) + const headers = { ...protectedHeaders } + if (origin) headers.origin = origin + else delete (headers as { origin?: string }).origin + + const response = await fetch(`${baseUrl}/api/administration/invitations`, { + method: "POST", + headers, + body: JSON.stringify({ + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.administration.invite.mock.calls).toEqual([]) + }) + + it("rejects membership changes without matching CSRF before calling the service", async () => { + const authentication = runtime() + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/memberships/membership`, { + method: "PATCH", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + body: JSON.stringify({ status: "suspended" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.administration.changeMembership.mock.calls).toEqual([]) + }) + + it("preserves the last-administrator conflict shape", async () => { + const authentication = runtime({ + changeMembership: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 409, + "last_administrator", + "The final active administrator cannot be changed.", + ), + ), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/memberships/membership`, { + method: "PATCH", + headers: protectedHeaders, + body: JSON.stringify({ role: "member" }), + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: "last_administrator", + message: "The final active administrator cannot be changed.", + }) + }) + + it("delegates invitation revocation with the authenticated session", async () => { + const authentication = runtime({ + revokeInvitation: vi.fn().mockResolvedValue(undefined), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/invitations/invitation`, { + method: "DELETE", + headers: protectedHeaders, + }) + + expect(response.status).toBe(204) + expect(authentication.administration.revokeInvitation.mock.calls).toEqual([ + [sessionToken, "invitation"], + ]) + }) +}) diff --git a/hub/server/routes/api/administration/get/index.ts b/hub/server/routes/api/administration/get/index.ts new file mode 100644 index 00000000..32c85cab --- /dev/null +++ b/hub/server/routes/api/administration/get/index.ts @@ -0,0 +1,16 @@ +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" + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + response.json( + await authentication.administration.list(readCookie(request.headers.cookie, cookies.session)), + ) +} diff --git a/hub/server/routes/api/administration/invitations/delete/index.ts b/hub/server/routes/api/administration/invitations/delete/index.ts new file mode 100644 index 00000000..5fc68ebd --- /dev/null +++ b/hub/server/routes/api/administration/invitations/delete/index.ts @@ -0,0 +1,23 @@ +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" + +export async function remove( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.administration.revokeInvitation( + readCookie(request.headers.cookie, cookies.session), + typeof request.params.id === "string" ? request.params.id : "", + ) + response.status(204).end() +} diff --git a/hub/server/routes/api/administration/invitations/post/index.ts b/hub/server/routes/api/administration/invitations/post/index.ts new file mode 100644 index 00000000..0a9b576c --- /dev/null +++ b/hub/server/routes/api/administration/invitations/post/index.ts @@ -0,0 +1,23 @@ +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" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const invitation = await authentication.administration.invite( + readCookie(request.headers.cookie, cookies.session), + request.body, + ) + response.status(201).json({ invitation }) +} diff --git a/hub/server/routes/api/administration/memberships/patch/index.ts b/hub/server/routes/api/administration/memberships/patch/index.ts new file mode 100644 index 00000000..aaa04c8d --- /dev/null +++ b/hub/server/routes/api/administration/memberships/patch/index.ts @@ -0,0 +1,24 @@ +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" + +export async function patch( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const membership = await authentication.administration.changeMembership( + readCookie(request.headers.cookie, cookies.session), + typeof request.params.id === "string" ? request.params.id : "", + request.body, + ) + response.json({ membership }) +} diff --git a/hub/server/routes/api/auth/callback/get/index.ts b/hub/server/routes/api/auth/callback/get/index.ts new file mode 100644 index 00000000..23cbc8cb --- /dev/null +++ b/hub/server/routes/api/auth/callback/get/index.ts @@ -0,0 +1,73 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { AuthenticationError } from "#server/utils/authentication-error" +import { readCookie } from "#server/utils/read-cookie" + +export interface AuthenticationCallbackOptions { + maximumCodeLength: number +} + +function isSingleString(value: unknown): value is string { + return typeof value === "string" +} + +function failureUrl( + origin: string, + reason: "invalid_callback" | "provider_error" | "provider_role_changed", +): string { + const url = new URL(origin) + url.searchParams.set("authentication", "failed") + url.searchParams.set("reason", reason) + return url.href +} + +export async function get( + options: AuthenticationCallbackOptions, + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + response.clearCookie(cookies.authorization, cookies.privateOptions) + if (request.query.error !== undefined) { + response.redirect(303, failureUrl(authentication.settings.origin, "provider_error")) + return + } + const { code, state } = request.query + if ( + !isSingleString(code) || + code.length === 0 || + code.length > options.maximumCodeLength || + !isSingleString(state) || + !/^[A-Za-z0-9_-]{43}$/.test(state) + ) { + response.redirect(303, failureUrl(authentication.settings.origin, "invalid_callback")) + return + } + try { + const result = await authentication.service.complete( + code, + state, + readCookie(request.headers.cookie, cookies.authorization), + ) + if (result.sessionToken) { + response.cookie(cookies.session, result.sessionToken, cookies.privateOptions) + } + response.cookie(cookies.csrf, randomBytes(32).toString("base64url"), cookies.csrfOptions) + response.redirect(303, `${authentication.settings.origin}/?authentication=complete`) + } catch (error) { + if (error instanceof AuthenticationError) { + response.redirect( + 303, + error.code === "provider_role_changed" + ? failureUrl(authentication.settings.origin, "provider_role_changed") + : `${authentication.settings.origin}/?authentication=failed`, + ) + return + } + throw error + } +} diff --git a/hub/server/routes/api/auth/enrollment/post/index.ts b/hub/server/routes/api/auth/enrollment/post/index.ts new file mode 100644 index 00000000..92795e3f --- /dev/null +++ b/hub/server/routes/api/auth/enrollment/post/index.ts @@ -0,0 +1,33 @@ +import type { Request, Response } from "express" + +import type { AuthorizationPurpose } from "#server/domains/authentication/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { requireCsrfToken } from "#server/utils/require-csrf-token" +import { readCookie } from "#server/utils/read-cookie" + +export async function post( + authentication: AuthenticationRoutes, + purpose: Extract, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const secret = + request.body && + typeof request.body === "object" && + !Array.isArray(request.body) && + typeof (request.body as Record).secret === "string" + ? (request.body as Record).secret + : "" + const attempt = await authentication.service.start(purpose, undefined, secret) + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} diff --git a/hub/server/routes/api/auth/github/callback/get/index.ts b/hub/server/routes/api/auth/github/callback/get/index.ts new file mode 100644 index 00000000..7d844802 --- /dev/null +++ b/hub/server/routes/api/auth/github/callback/get/index.ts @@ -0,0 +1,19 @@ +import type { Request, Response } from "express" + +import { get as handleAuthenticationCallback } from "#server/routes/api/auth/callback/get" +import type { AuthenticationRoutes } from "#server/routes/types" + +export const maximumGitHubAuthorizationCodeLength = 1_000 + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await handleAuthenticationCallback( + { maximumCodeLength: maximumGitHubAuthorizationCodeLength }, + authentication, + request, + response, + ) +} diff --git a/hub/server/routes/api/auth/github/post/index.ts b/hub/server/routes/api/auth/github/post/index.ts new file mode 100644 index 00000000..dc98fd36 --- /dev/null +++ b/hub/server/routes/api/auth/github/post/index.ts @@ -0,0 +1,24 @@ +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" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const attempt = await authentication.service.start("sign-in") + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} diff --git a/hub/server/routes/api/auth/oidc/callback/get/index.ts b/hub/server/routes/api/auth/oidc/callback/get/index.ts new file mode 100644 index 00000000..522592e2 --- /dev/null +++ b/hub/server/routes/api/auth/oidc/callback/get/index.ts @@ -0,0 +1,19 @@ +import type { Request, Response } from "express" + +import { get as handleAuthenticationCallback } from "#server/routes/api/auth/callback/get" +import type { AuthenticationRoutes } from "#server/routes/types" + +export const maximumOidcAuthorizationCodeLength = 8_192 + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await handleAuthenticationCallback( + { maximumCodeLength: maximumOidcAuthorizationCodeLength }, + authentication, + request, + response, + ) +} diff --git a/hub/server/routes/api/auth/oidc/post/index.ts b/hub/server/routes/api/auth/oidc/post/index.ts new file mode 100644 index 00000000..f72cc858 --- /dev/null +++ b/hub/server/routes/api/auth/oidc/post/index.ts @@ -0,0 +1 @@ +export { post } from "#server/routes/api/auth/github/post" diff --git a/hub/server/routes/api/session/delete/index.ts b/hub/server/routes/api/session/delete/index.ts new file mode 100644 index 00000000..bdc2c946 --- /dev/null +++ b/hub/server/routes/api/session/delete/index.ts @@ -0,0 +1,26 @@ +import { randomBytes } from "node:crypto" +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" + +async function deleteSession( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.service.signOut(readCookie(request.headers.cookie, cookies.session)) + response.clearCookie(cookies.session, cookies.privateOptions) + response.clearCookie(cookies.authorization, cookies.privateOptions) + response.cookie(cookies.csrf, randomBytes(32).toString("base64url"), cookies.csrfOptions) + response.status(204).end() +} + +export { deleteSession as delete } diff --git a/hub/server/routes/api/session/get/index.ts b/hub/server/routes/api/session/get/index.ts new file mode 100644 index 00000000..2026ab95 --- /dev/null +++ b/hub/server/routes/api/session/get/index.ts @@ -0,0 +1,59 @@ +import { randomBytes } from "node:crypto" +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { AuthenticationError } from "#server/utils/authentication-error" +import { readCookie } from "#server/utils/read-cookie" + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + let csrfToken = readCookie(request.headers.cookie, cookies.csrf) + if (!csrfToken) { + csrfToken = randomBytes(32).toString("base64url") + response.cookie(cookies.csrf, csrfToken, cookies.csrfOptions) + } + const sessionToken = readCookie(request.headers.cookie, cookies.session) + try { + const current = await authentication.service.current(sessionToken) + response.json({ + authenticated: true, + configured: authentication.settings.configured, + provider: authentication.settings.provider, + providerLabel: authentication.settings.providerLabel, + admissionPolicy: authentication.settings.admissionPolicy, + csrfToken, + user: { + id: current.user.id, + displayName: current.user.displayName, + avatarUrl: current.user.avatarUrl, + provider: current.externalIdentity.provider, + username: current.externalIdentity.username, + }, + session: { + id: current.id, + signedInAt: current.signedInAt, + freshAuthorizedAt: current.freshAuthorizedAt, + }, + membership: { + id: current.membership.id, + role: current.membership.role, + status: current.membership.status, + }, + }) + } catch (error) { + if (!(error instanceof AuthenticationError) || error.status !== 401) throw error + response.json({ + authenticated: false, + configured: authentication.settings.configured, + provider: authentication.settings.provider, + providerLabel: authentication.settings.providerLabel, + admissionPolicy: authentication.settings.admissionPolicy, + csrfToken, + }) + } +} diff --git a/hub/server/routes/api/session/revoke-all/post/index.ts b/hub/server/routes/api/session/revoke-all/post/index.ts new file mode 100644 index 00000000..6736ae19 --- /dev/null +++ b/hub/server/routes/api/session/revoke-all/post/index.ts @@ -0,0 +1,21 @@ +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" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.service.revokeAll(readCookie(request.headers.cookie, cookies.session)) + response.clearCookie(cookies.session, cookies.privateOptions) + response.status(204).end() +} diff --git a/hub/server/routes/api/session/verify/post/index.ts b/hub/server/routes/api/session/verify/post/index.ts new file mode 100644 index 00000000..fac5b672 --- /dev/null +++ b/hub/server/routes/api/session/verify/post/index.ts @@ -0,0 +1,27 @@ +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" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const attempt = await authentication.service.start( + "verify", + readCookie(request.headers.cookie, cookies.session), + ) + response.cookie(cookies.authorization, attempt.browserToken, { + ...cookies.privateOptions, + maxAge: 10 * 60_000, + }) + response.json({ authorizationUrl: attempt.authorizationUrl }) +} diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts new file mode 100644 index 00000000..c14c3fab --- /dev/null +++ b/hub/server/routes/authentication.test.ts @@ -0,0 +1,747 @@ +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 { 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" + +import { createApp } from "../create-app/index.ts" + +function authentication( + overrides: Partial> = {}, + secureCookies = true, + trustedProxyHops = 0, + provider: "github" | "oidc" = "github", +) { + const service = Object.assign( + { + start: vi.fn().mockResolvedValue({ + authorizationUrl: "https://github.com/login/oauth/authorize?state=redacted", + browserToken: "browser-token", + }), + complete: vi.fn().mockResolvedValue({ + purpose: "sign-in", + sessionToken: "session-token", + }), + current: vi + .fn() + .mockRejectedValue( + new AuthenticationError(401, "sign_in_required", "Sign in to continue."), + ), + signOut: vi.fn().mockResolvedValue(undefined), + revokeAll: vi.fn().mockResolvedValue(undefined), + }, + overrides, + ) + const administration = { + list: vi.fn().mockResolvedValue({ memberships: [], invitations: [] }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + } + return { + settings: { + origin: "https://hub.example", + secureCookies, + configured: true, + provider, + providerLabel: provider === "oidc" ? "Company SSO" : "GitHub", + admissionPolicy: "hub-managed" as const, + trustedProxyHops, + }, + service, + administration, + prototypeAuthorization: {} as PrototypeAuthorizationService, + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +async function serve(runtime: AuthenticationRoutes, logger = createLogger()) { + const server = createServer(createApp({ authentication: runtime, 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}` +} + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const browserToken = "b".repeat(43) +const csrfHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "x-csrf-token": csrfToken, +} + +function opaqueOidcCode(length: number): string { + return `oidc.${"Ab9_-".repeat(Math.ceil(length / 5))}`.slice(0, length) +} + +describe("authentication routes", () => { + it.each([ + [ + "github", + "/api/auth/github", + "/api/auth/github/callback", + "/api/auth/oidc", + "/api/auth/oidc/callback", + ], + [ + "oidc", + "/api/auth/oidc", + "/api/auth/oidc/callback", + "/api/auth/github", + "/api/auth/github/callback", + ], + ] as const)( + "registers only the %s provider initiation and callback pair", + async (_providerName, selectedInitiation, selectedCallback, otherInitiation, otherCallback) => { + const runtime = authentication({}, true, 0, _providerName) + const baseUrl = await serve(runtime) + + expect((await fetch(`${baseUrl}${selectedInitiation}`, { method: "POST" })).status).toBe(403) + expect((await fetch(`${baseUrl}${selectedCallback}`, { redirect: "manual" })).status).toBe( + 303, + ) + expect( + ( + await fetch(`${baseUrl}${otherInitiation}`, { + method: "POST", + headers: csrfHeaders, + }) + ).status, + ).toBe(404) + expect((await fetch(`${baseUrl}${otherCallback}`, { redirect: "manual" })).status).toBe(404) + }, + ) + + it("sets host-bound secure cookies and omits repository scope during GitHub initiation", async () => { + const runtime = authentication({ + start: vi.fn().mockResolvedValue({ + authorizationUrl: + "https://github.com/login/oauth/authorize?client_id=client&state=state&code_challenge=challenge&code_challenge_method=S256", + browserToken, + }), + }) + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: csrfHeaders, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + authorizationUrl: + "https://github.com/login/oauth/authorize?client_id=client&state=state&code_challenge=challenge&code_challenge_method=S256", + }) + expect(response.headers.get("set-cookie")).toMatch( + new RegExp( + `__Host-replica-authorization=${browserToken}; Max-Age=600; Path=/; Expires=[^;]+; HttpOnly; Secure; SameSite=Lax`, + "i", + ), + ) + expect(runtime.service.start).toHaveBeenCalledWith("sign-in") + }) + + it("uses the same protected initiation and opaque callback session contract for OIDC", async () => { + const runtime = authentication( + { + start: vi.fn().mockResolvedValue({ + authorizationUrl: + "https://login.example.test/tenant/authorize?state=state&nonce=nonce&code_challenge=challenge&code_challenge_method=S256", + browserToken, + }), + complete: vi.fn().mockResolvedValue({ + purpose: "sign-in", + sessionToken, + }), + }, + true, + 0, + "oidc", + ) + const baseUrl = await serve(runtime) + + const unprotected = await fetch(`${baseUrl}/api/auth/oidc`, { method: "POST" }) + expect(unprotected.status).toBe(403) + expect(runtime.service.start).not.toHaveBeenCalled() + + const initiation = await fetch(`${baseUrl}/api/auth/oidc`, { + method: "POST", + headers: csrfHeaders, + }) + expect(initiation.status).toBe(200) + expect(await initiation.json()).toEqual({ + authorizationUrl: + "https://login.example.test/tenant/authorize?state=state&nonce=nonce&code_challenge=challenge&code_challenge_method=S256", + }) + expect(initiation.headers.get("set-cookie")).toContain( + `__Host-replica-authorization=${browserToken}`, + ) + expect(runtime.service.start).toHaveBeenCalledWith("sign-in") + + const callback = await fetch( + `${baseUrl}/api/auth/oidc/callback?code=provider-code&state=${"s".repeat(43)}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + expect(callback.status).toBe(303) + expect(callback.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(callback.headers.get("set-cookie")).toContain(`__Host-replica-session=${sessionToken}`) + expect(callback.headers.get("set-cookie")).not.toContain("provider-code") + expect(runtime.service.complete).toHaveBeenCalledWith( + "provider-code", + "s".repeat(43), + browserToken, + ) + }) + + it("completes an OIDC callback with a realistic long opaque code without exposing it", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const code = opaqueOidcCode(2_048) + const state = "S_-09azAZ".repeat(5).slice(0, 43) + const runtime = authentication( + { + complete: vi.fn().mockResolvedValue({ + purpose: "sign-in", + sessionToken, + }), + }, + true, + 0, + "oidc", + ) + const baseUrl = await serve(runtime, logger) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ code, state })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.status).toBe(303) + expect(response.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(response.headers.get("set-cookie")).toContain(`__Host-replica-session=${sessionToken}`) + expect(runtime.service.complete).toHaveBeenCalledWith(code, state, browserToken) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify([...response.headers])).not.toContain(code) + expect(JSON.stringify(records)).not.toContain(code) + }) + + it("keeps GitHub authorization codes at the 1000-character boundary", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + const state = "s".repeat(43) + const acceptedCode = "g".repeat(1_000) + const rejectedCode = "g".repeat(1_001) + + const accepted = await fetch( + `${baseUrl}/api/auth/github/callback?${new URLSearchParams({ + code: acceptedCode, + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + const rejected = await fetch( + `${baseUrl}/api/auth/github/callback?${new URLSearchParams({ + code: rejectedCode, + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(accepted.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(rejected.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=invalid_callback", + ) + expect(runtime.service.complete).toHaveBeenCalledOnce() + expect(runtime.service.complete).toHaveBeenCalledWith(acceptedCode, state, browserToken) + }) + + it("accepts an 8192-character OIDC code and rejects the next character", async () => { + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime) + const state = "s".repeat(43) + const acceptedCode = opaqueOidcCode(8_192) + const rejectedCode = opaqueOidcCode(8_193) + + const accepted = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + code: acceptedCode, + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + const rejected = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + code: rejectedCode, + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(accepted.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(rejected.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=invalid_callback", + ) + expect(runtime.service.complete).toHaveBeenCalledOnce() + expect(runtime.service.complete).toHaveBeenCalledWith(acceptedCode, state, browserToken) + }) + + it("accepts exactly one 43-character base64url state value", async () => { + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime) + const state = "Az09_-".repeat(8).slice(0, 43) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + code: "provider-code", + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.headers.get("location")).toBe("https://hub.example/?authentication=complete") + expect(runtime.service.complete).toHaveBeenCalledWith("provider-code", state, browserToken) + }) + + it.each([ + ["42 characters", "s".repeat(42)], + ["44 characters", "s".repeat(44)], + ["a non-base64url character", `${"s".repeat(42)}+`], + ])("rejects state with %s before completion", async (_name, state) => { + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + code: "provider-code", + state, + })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=invalid_callback", + ) + expect(runtime.service.complete).not.toHaveBeenCalled() + }) + + it.each([ + ["duplicate code", `code=first&code=second&state=${"s".repeat(43)}`], + ["duplicate state", `code=provider-code&state=${"s".repeat(43)}&state=${"t".repeat(43)}`], + ["missing code", `state=${"s".repeat(43)}`], + ["missing state", "code=provider-code"], + ["empty code", `code=&state=${"s".repeat(43)}`], + ["empty state", "code=provider-code&state="], + ])("rejects a callback with %s before completion", async (_name, query) => { + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/auth/oidc/callback?${query}`, { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }) + + expect(response.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=invalid_callback", + ) + expect(runtime.service.complete).not.toHaveBeenCalled() + }) + + it("uses only a safe provider error reason and omits provider callback values from logs", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication({}, true, 0, "oidc") + const baseUrl = await serve(runtime, logger) + const errorDescription = "private provider explanation" + const code = opaqueOidcCode(1_024) + const state = "s".repeat(43) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ + error: "access_denied", + error_description: errorDescription, + code, + state, + })}`, + { redirect: "manual" }, + ) + + expect(response.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=provider_error", + ) + expect(runtime.service.complete).not.toHaveBeenCalled() + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify(records)).not.toMatch( + new RegExp(`${errorDescription}|${code}|${state}|access_denied`), + ) + }) + + it("rejects state-changing requests unless both the origin and CSRF token match", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const wrongOrigin = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: { ...csrfHeaders, origin: "https://attacker.example" }, + }) + expect(wrongOrigin.status).toBe(403) + expect(await wrongOrigin.json()).toEqual({ error: "Request not allowed" }) + + const wrongCsrf = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: { ...csrfHeaders, "x-csrf-token": "wrong-token" }, + }) + expect(wrongCsrf.status).toBe(403) + expect(await wrongCsrf.json()).toEqual({ error: "Request not allowed" }) + expect(runtime.service.signOut).not.toHaveBeenCalled() + }) + + it("omits rejected auth payloads before origin middleware can finish the request", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication() + const baseUrl = await serve(runtime, logger) + + const response = await fetch(`${baseUrl}/api/auth/github?note=query-private-value`, { + method: "POST", + headers: { + ...csrfHeaders, + origin: "https://attacker.example", + "content-type": "application/json", + }, + body: JSON.stringify({ note: "body-private-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(/query-private-value|body-private-value/) + }) + + it.each([ + { + name: "malformed", + body: '{"note":"body-private-value"', + status: 400, + }, + { + name: "oversized", + body: JSON.stringify({ note: "body-private-value".repeat(100_000) }), + status: 413, + }, + ])("omits auth payloads when the JSON parser rejects a $name body", async ({ body, status }) => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const baseUrl = await serve(authentication(), logger) + + const response = await fetch(`${baseUrl}/api/auth/github?note=query-private-value`, { + method: "POST", + headers: { + ...csrfHeaders, + "content-type": "application/json", + }, + body, + }) + + expect(response.status).toBe(status) + 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(/query-private-value|body-private-value/) + }) + + it("rotates browser state on sign-out and delegates revoke-all only with a protected request", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const signOut = await fetch(`${baseUrl}/api/session`, { + method: "DELETE", + headers: csrfHeaders, + }) + expect(signOut.status).toBe(204) + expect(runtime.service.signOut).toHaveBeenCalledWith(sessionToken) + const signOutCookies = signOut.headers.get("set-cookie") ?? "" + expect(signOutCookies).toContain("__Host-replica-session=") + expect(signOutCookies).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT") + expect(signOutCookies).toMatch(/__Host-replica-csrf=[\w-]{43}/) + + const revokeAll = await fetch(`${baseUrl}/api/session/revoke-all`, { + method: "POST", + headers: csrfHeaders, + }) + expect(revokeAll.status).toBe(204) + expect(runtime.service.revokeAll).toHaveBeenCalledWith(sessionToken) + }) + + it("redirects callback failures generically and never logs OAuth secrets", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication({ + complete: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 401, + "invalid_authorization", + "private callback failure containing a token", + ), + ), + }) + const baseUrl = await serve(runtime, logger) + const state = "s".repeat(43) + + const response = await fetch( + `${baseUrl}/api/auth/github/callback?code=private-code&state=${state}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.status).toBe(303) + expect(response.headers.get("location")).toBe("https://hub.example/?authentication=failed") + expect(response.headers.get("set-cookie")).toMatch( + /__Host-replica-authorization=; Path=\/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax/i, + ) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify(records)).not.toMatch( + /private-code|bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb|callback failure|token/, + ) + }) + + it("preserves the provider role recovery reason without exposing callback secrets", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const runtime = authentication( + { + complete: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 403, + "provider_role_changed", + "private provider role reconciliation failure", + ), + ), + }, + true, + 0, + "oidc", + ) + const baseUrl = await serve(runtime, logger) + const code = "private-provider-code" + const state = "s".repeat(43) + + const response = await fetch( + `${baseUrl}/api/auth/oidc/callback?${new URLSearchParams({ code, state })}`, + { + headers: { cookie: `__Host-replica-authorization=${browserToken}` }, + redirect: "manual", + }, + ) + + expect(response.status).toBe(303) + expect(response.headers.get("location")).toBe( + "https://hub.example/?authentication=failed&reason=provider_role_changed", + ) + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(JSON.stringify([...response.headers])).not.toMatch( + /private-provider-code|sssssssssssssssssssssssssssssssssssssssssss/, + ) + expect(JSON.stringify(records)).not.toMatch( + /private-provider-code|sssssssssssssssssssssssssssssssssssssssssss|role reconciliation/, + ) + }) + + it("allows the initiation limit and rejects the next request without calling the service", async () => { + const runtime = authentication() + const baseUrl = await serve(runtime) + + const responses = [] + for (let request = 0; request < 21; request++) { + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: { + ...csrfHeaders, + "x-forwarded-for": `198.51.100.${request + 1}`, + }, + }), + ) + } + + expect(responses.slice(0, 20).every(({ status }) => status === 200)).toBe(true) + const limitedResponse = responses[20] + expect(limitedResponse).toBeDefined() + expect(limitedResponse!.status).toBe(429) + expect(await limitedResponse!.json()).toEqual({ error: "Too many requests" }) + expect(runtime.service.start).toHaveBeenCalledTimes(20) + }) + + it("separates rate limits by forwarded client only behind the configured proxy boundary", async () => { + const runtime = authentication({}, true, 1) + const baseUrl = await serve(runtime) + + const responses = [] + for (let request = 0; request < 20; request++) { + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: { + ...csrfHeaders, + "x-forwarded-for": "198.51.100.10", + }, + }), + ) + } + responses.push( + await fetch(`${baseUrl}/api/auth/github`, { + method: "POST", + headers: { + ...csrfHeaders, + "x-forwarded-for": "203.0.113.20", + }, + }), + ) + + expect(responses.every(({ status }) => status === 200)).toBe(true) + expect(runtime.service.start).toHaveBeenCalledTimes(21) + }) + + it("returns session state without exposing the session token and creates a readable CSRF cookie", async () => { + const runtime = authentication({ + current: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: "revision", + tokenDigest: "stored-digest", + signedInAt: new Date("2026-10-27T18:00:00.000Z"), + freshAuthorizedAt: null, + user: { id: "user", displayName: "The Octocat", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "user", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "octocat", + displayName: "The Octocat", + avatarUrl: null, + }, + authenticationProviderRevision: { + id: "revision", + authenticationProviderId: "provider", + revision: 1, + issuer: "https://github.com", + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, + }, + membership: { + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "invitation", + suspendedAt: null, + removedAt: null, + }, + }), + }) + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/session`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + const body: unknown = await response.json() + + expect(response.status).toBe(200) + expect(body).toMatchObject({ + authenticated: true, + configured: true, + provider: "github", + providerLabel: "GitHub", + admissionPolicy: "hub-managed", + user: { id: "user", provider: "github", username: "octocat" }, + session: { id: "session", freshAuthorizedAt: null }, + }) + expect(isRecord(body) ? body.csrfToken : undefined).toMatch(/^[\w-]{43}$/) + expect(response.headers.get("set-cookie")).toMatch( + /__Host-replica-csrf=[\w-]{43}; Path=\/; Secure; SameSite=Lax/i, + ) + expect(response.headers.get("set-cookie")).not.toContain("HttpOnly") + expect(JSON.stringify(body)).not.toContain(sessionToken) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }) +}) diff --git a/hub/server/routes/github-authorization/index.test.ts b/hub/server/routes/github-authorization/index.test.ts new file mode 100644 index 00000000..0a2c3a0a --- /dev/null +++ b/hub/server/routes/github-authorization/index.test.ts @@ -0,0 +1,136 @@ +import { once } from "node:events" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { describe, expect, it, onTestFinished, vi } from "vitest" +import type { Mocked } from "vitest" + +import type { AdministrationService } from "#server/domains/administration/create-service/types" +import type { AuthenticationService } from "#server/domains/authentication/create-service/types" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" +import type { PrototypeAuthorizationService } from "#server/domains/prototype-authorization/create-service/types" +import type { AuthenticationRoutes } from "#server/routes/types" + +import { createApp } from "../../create-app/index.ts" + +const csrfToken = "c".repeat(43) +const sessionToken = "s".repeat(43) +const protectedHeaders = { + origin: "https://hub.example", + cookie: `__Host-replica-csrf=${csrfToken}; __Host-replica-session=${sessionToken}`, + "content-type": "application/json", + "x-csrf-token": csrfToken, +} + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { githubAuthorization: Mocked } { + const githubAuthorization = Object.assign( + { + evaluateAdmission: vi.fn(), + saveAdmissionBaseline: vi.fn(), + prototypeBaseline: vi.fn(), + listRepositories: vi.fn().mockResolvedValue([]), + linkRepository: vi.fn(), + updateRepository: vi.fn(), + unlinkRepository: vi.fn(), + reconcileRepository: vi.fn(), + reconcileDue: vi.fn(), + processWebhook: vi.fn(), + }, + overrides, + ) as Mocked + return { + service: {} as Mocked, + administration: {} as Mocked, + prototypeAuthorization: {} as Mocked, + githubAuthorization, + settings: { + origin: "https://hub.example", + secureCookies: true, + configured: true, + admissionPolicy: "hub-managed", + trustedProxyHops: 0, + }, + } +} + +async function serve(authentication: AuthenticationRoutes) { + const server = createServer(createApp({ authentication })) + server.listen(0, "127.0.0.1") + await once(server, "listening") + onTestFinished( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }), + ) + const { port } = server.address() as AddressInfo + return `http://127.0.0.1:${port}` +} + +describe("GitHub authorization routes", () => { + it("passes the authenticated session to repository listing without accepting a body token", async () => { + const repository = { id: "repository-source" } + const listRepositories = vi.fn().mockResolvedValue([repository]) + const authentication = runtime({ listRepositories }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/github/repositories`, { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ repositories: [repository] }) + expect(listRepositories).toHaveBeenCalledWith(sessionToken) + }) + + it("rejects repository mutations before the service when CSRF does not match", async () => { + const linkRepository = vi.fn() + const authentication = runtime({ linkRepository }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/github/repositories`, { + method: "POST", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + body: JSON.stringify({ githubRepositoryId: "123" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(linkRepository).not.toHaveBeenCalled() + }) + + it.each([ + ["accepted", 202], + ["duplicate", 200], + ["ignored", 200], + ] as const)( + "forwards the verified webhook envelope and returns %s as HTTP %i", + async (result, status) => { + const body = Buffer.from('{"installation":{"id":7}}') + const processWebhook = vi.fn().mockResolvedValue(result) + const authentication = runtime({ processWebhook }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/github/webhooks`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-delivery": "delivery-1", + "x-github-event": "installation", + "x-hub-signature-256": "sha256=signature", + }, + body, + }) + + expect(response.status).toBe(status) + expect(await response.json()).toEqual({ result }) + expect(processWebhook).toHaveBeenCalledWith({ + deliveryId: "delivery-1", + event: "installation", + signature: "sha256=signature", + body, + }) + }, + ) +}) diff --git a/hub/server/routes/github-authorization/index.ts b/hub/server/routes/github-authorization/index.ts new file mode 100644 index 00000000..1af030cd --- /dev/null +++ b/hub/server/routes/github-authorization/index.ts @@ -0,0 +1,103 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +function sessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + return readCookie(request.headers.cookie, cookies.session) +} + +function mutationSessionToken(authentication: AuthenticationRoutes, request: Request): string { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + return readCookie(request.headers.cookie, cookies.session) +} + +function parameter(request: Request, name: string): string { + const value = request.params[name] + return typeof value === "string" ? value : "" +} + +export async function listRepositories( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + response.json({ + repositories: await authentication.githubAuthorization!.listRepositories( + sessionToken(authentication, request), + ), + }) +} + +export async function linkRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.linkRepository( + mutationSessionToken(authentication, request), + request.body, + ) + response.status(201).json({ repository }) +} + +export async function updateRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.updateRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + request.body, + ) + response.json({ repository }) +} + +export async function unlinkRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + await authentication.githubAuthorization!.unlinkRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + ) + response.status(204).end() +} + +export async function reconcileRepository( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + const repository = await authentication.githubAuthorization!.reconcileRepository( + mutationSessionToken(authentication, request), + parameter(request, "repositorySourceId"), + ) + response.json({ repository }) +} + +export async function processWebhook( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +) { + response.set("Cache-Control", "private, no-store") + response.set("Referrer-Policy", "no-referrer") + const body = Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0) + const result = await authentication.githubAuthorization!.processWebhook({ + deliveryId: request.get("x-github-delivery") ?? "", + event: request.get("x-github-event") ?? "", + signature: request.get("x-hub-signature-256") ?? "", + body, + }) + response.status(result === "accepted" ? 202 : 200).json({ result }) +} diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index e5216064..31e1afb1 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -1,8 +1,225 @@ import type { Express } from "express" +import { createRateLimit } from "#server/integrations/express/create-rate-limit" +import { prepareAuthenticationRequest } from "#server/integrations/express/prepare-authentication-request" +import { respondAuthenticationError } from "#server/integrations/express/respond-authentication-error" +import { respondAdministrationError } from "#server/integrations/express/respond-administration-error" +import { get as handleApiAuthGithubCallbackGet } from "#server/routes/api/auth/github/callback/get" +import { post as handleApiAuthGithubPost } from "#server/routes/api/auth/github/post" +import { get as handleApiAuthOidcCallbackGet } from "#server/routes/api/auth/oidc/callback/get" +import { post as handleApiAuthOidcPost } from "#server/routes/api/auth/oidc/post" +import { post as handleApiAuthEnrollmentPost } from "#server/routes/api/auth/enrollment/post" +import { get as handleApiAdministrationGet } from "#server/routes/api/administration/get" +import { post as handleApiAdministrationInvitationsPost } from "#server/routes/api/administration/invitations/post" +import { remove as handleApiAdministrationInvitationsDelete } from "#server/routes/api/administration/invitations/delete" +import { patch as handleApiAdministrationMembershipsPatch } from "#server/routes/api/administration/memberships/patch" import { get as handleApiHealthGet } from "./api/health/get/index.ts" +import { delete as handleApiSessionDelete } from "#server/routes/api/session/delete" +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, + createPublisherCredential, + createWorkspace, + explainPrototypeAccess, + getPrototype, + getVersion, + getWorkspace, + listPublishers, + listPrototypes, + listVersions, + listWorkspaces, + removePrototypeAccess, + removeWorkspaceAccess, + revokePublisherCredential, + setPublisherActive, + setPrototypeAccess, + setPrototypeAuthorization, + setWorkspaceAccess, +} from "#server/routes/product" +import { + linkRepository, + listRepositories, + processWebhook, + reconcileRepository, + unlinkRepository, + updateRepository, +} from "#server/routes/github-authorization" +import { getPublicationStatus, promoteVersion, publishArtifact } from "#server/routes/publishing" +import type { AuthenticationRoutes } from "#server/routes/types" /** Keep endpoint registration separate from the app's shared middleware. */ -export function routes(app: Express): void { +export function routes(app: Express, authentication?: AuthenticationRoutes): void { app.get("/api/health", handleApiHealthGet) + if (!authentication) return + if (authentication.githubAuthorization) { + app.post("/api/github/webhooks", processWebhook.bind(null, authentication)) + } + + app.use( + ["/api/auth", "/api/session", "/api/administration", "/api/workspaces", "/api/prototypes"], + prepareAuthenticationRequest.bind(null, authentication.settings.origin), + ) + const initiationLimit = createRateLimit({ + limit: 20, + windowMilliseconds: 5 * 60_000, + }) + const callbackLimit = createRateLimit({ + limit: 60, + windowMilliseconds: 5 * 60_000, + }) + if (authentication.settings.provider === "github") { + app.post( + "/api/auth/github", + initiationLimit, + handleApiAuthGithubPost.bind(null, authentication), + ) + app.get( + "/api/auth/github/callback", + callbackLimit, + handleApiAuthGithubCallbackGet.bind(null, authentication), + ) + } + if (authentication.settings.provider === "oidc") { + app.post("/api/auth/oidc", initiationLimit, handleApiAuthOidcPost.bind(null, authentication)) + app.get( + "/api/auth/oidc/callback", + callbackLimit, + handleApiAuthOidcCallbackGet.bind(null, authentication), + ) + } + app.get("/api/session", handleApiSessionGet.bind(null, authentication)) + app.delete("/api/session", handleApiSessionDelete.bind(null, authentication)) + app.post( + "/api/session/verify", + initiationLimit, + handleApiSessionVerifyPost.bind(null, authentication), + ) + app.post("/api/session/revoke-all", handleApiSessionRevokeAllPost.bind(null, authentication)) + app.post( + "/api/auth/bootstrap", + initiationLimit, + handleApiAuthEnrollmentPost.bind(null, authentication, "bootstrap"), + ) + app.post( + "/api/auth/recovery", + initiationLimit, + handleApiAuthEnrollmentPost.bind(null, authentication, "recovery"), + ) + app.get("/api/administration", handleApiAdministrationGet.bind(null, authentication)) + app.post( + "/api/administration/invitations", + handleApiAdministrationInvitationsPost.bind(null, authentication), + ) + app.delete( + "/api/administration/invitations/:id", + handleApiAdministrationInvitationsDelete.bind(null, authentication), + ) + app.patch( + "/api/administration/memberships/:id", + handleApiAdministrationMembershipsPatch.bind(null, authentication), + ) + if (authentication.githubAuthorization) { + app.get("/api/administration/github/repositories", listRepositories.bind(null, authentication)) + app.post("/api/administration/github/repositories", linkRepository.bind(null, authentication)) + app.patch( + "/api/administration/github/repositories/:repositorySourceId", + updateRepository.bind(null, authentication), + ) + app.delete( + "/api/administration/github/repositories/:repositorySourceId", + unlinkRepository.bind(null, authentication), + ) + app.post( + "/api/administration/github/repositories/:repositorySourceId/reconcile", + reconcileRepository.bind(null, authentication), + ) + } + app.use("/api/administration", respondAdministrationError) + const productMutationLimit = createRateLimit({ + limit: 120, + 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.put( + "/api/prototypes/:prototypeId/authorization", + productMutationLimit, + setPrototypeAuthorization.bind(null, authentication), + ) + app.post( + "/api/prototypes/:prototypeId/publishers", + productMutationLimit, + createPublisherCredential.bind(null, authentication), + ) + app.get("/api/prototypes/:prototypeId/publishers", listPublishers.bind(null, authentication)) + app.patch( + "/api/prototypes/:prototypeId/publishers/:publisherId", + productMutationLimit, + setPublisherActive.bind(null, authentication), + ) + app.delete( + "/api/prototypes/:prototypeId/publishers/:publisherId/credentials/:credentialId", + productMutationLimit, + revokePublisherCredential.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.get("/api/prototypes/:prototypeId/versions/:versionId", getVersion.bind(null, authentication)) + app.use(["/api/workspaces", "/api/prototypes"], respondAdministrationError) + if (authentication.artifactPublishing) { + app.post( + "/api/publishing/prototypes/:prototypeId/versions", + publishArtifact.bind(null, authentication.artifactPublishing), + ) + app.get( + "/api/publishing/prototypes/:prototypeId/status", + getPublicationStatus.bind(null, authentication.artifactPublishing), + ) + app.put( + "/api/publishing/prototypes/:prototypeId/channels/:channel", + promoteVersion.bind(null, authentication.artifactPublishing), + ) + app.use("/api/publishing", 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..1c1babd7 --- /dev/null +++ b/hub/server/routes/product/index.test.ts @@ -0,0 +1,448 @@ +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(), + setPrototypeAuthorization: vi.fn(), + createPublisherCredential: vi.fn(), + listPublishers: vi.fn(), + setPublisherActive: vi.fn(), + revokePublisherCredential: vi.fn(), + explainPrototypeAccess: vi.fn(), + setPrototypeAccess: vi.fn(), + removePrototypeAccess: vi.fn(), + listVersions: 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: "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/publishers", + call: "createPublisherCredential", + body: { name: "Local publisher", actions: ["publish"] }, + }, + { + method: "GET", + path: "/api/prototypes/prototype/publishers", + call: "listPublishers", + }, + { + method: "PATCH", + path: "/api/prototypes/prototype/publishers/publisher", + call: "setPublisherActive", + body: { active: false }, + }, + { + method: "DELETE", + path: "/api/prototypes/prototype/publishers/publisher/credentials/credential", + call: "revokePublisherCredential", + }, + { + method: "POST", + path: "/api/prototypes/prototype/archive", + call: "archivePrototype", + }, + { + method: "PUT", + path: "/api/prototypes/prototype/authorization", + call: "setPrototypeAuthorization", + body: { + mode: "github-synchronized", + repositorySourceId: "00000000-0000-0000-0000-000000000001", + }, + }, + { + method: "GET", + path: "/api/prototypes/prototype/access/explanation", + 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: "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("forwards prototype authorization changes through the protected HTTP route", async () => { + const saved = { + id: "prototype", + authorizationMode: "github-synchronized", + sourceRepositoryId: "00000000-0000-0000-0000-000000000001", + } + const authentication = runtime({ + setPrototypeAuthorization: vi.fn().mockResolvedValue(saved), + }) + const baseUrl = await serve(authentication) + const body = { + mode: "github-synchronized", + repositorySourceId: saved.sourceRepositoryId, + } + + const response = await fetch(`${baseUrl}/api/prototypes/prototype/authorization`, { + method: "PUT", + headers: protectedHeaders, + body: JSON.stringify(body), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ prototype: saved }) + expect( + authentication.prototypeAuthorization.setPrototypeAuthorization.mock.calls, + ).toContainEqual([sessionToken, "prototype", body]) + }) + + it.each([ + { + name: "missing origin", + 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("forwards publisher pagination and returns the bounded metadata page", async () => { + const page = { + items: [{ id: "publisher-id", credentials: [{ id: "credential-id" }] }], + nextCursor: "publisher-id", + } + const listPublishers = vi.fn().mockResolvedValue(page) + const authentication = runtime({ listPublishers }) + const baseUrl = await serve(authentication) + + const response = await fetch( + `${baseUrl}/api/prototypes/prototype/publishers?limit=1&cursor=cursor-id`, + { headers: { cookie: `__Host-replica-session=${sessionToken}` } }, + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(page) + expect(listPublishers).toHaveBeenCalledWith(sessionToken, "prototype", { + limit: "1", + cursor: "cursor-id", + }) + }) + + 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..f946f38a --- /dev/null +++ b/hub/server/routes/product/index.ts @@ -0,0 +1,273 @@ +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 setPrototypeAuthorization( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const prototype = await authentication.prototypeAuthorization.setPrototypeAuthorization( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + request.body, + ) + response.json({ prototype }) +} + +export async function createPublisherCredential( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const result = await authentication.prototypeAuthorization.createPublisherCredential( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + request.body, + ) + response.status(201).json(result) +} + +export async function listPublishers( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const publishers = await authentication.prototypeAuthorization.listPublishers( + sessionToken(authentication, request), + parameter(request, "prototypeId"), + request.query, + ) + response.json(publishers) +} + +export async function setPublisherActive( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const publisher = await authentication.prototypeAuthorization.setPublisherActive( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "publisherId"), + request.body, + ) + response.json({ publisher }) +} + +export async function revokePublisherCredential( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + await authentication.prototypeAuthorization.revokePublisherCredential( + mutationSessionToken(authentication, request), + parameter(request, "prototypeId"), + parameter(request, "publisherId"), + parameter(request, "credentialId"), + ) + response.status(204).end() +} + +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 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/publishing.test.ts b/hub/server/routes/publishing.test.ts new file mode 100644 index 00000000..eccca88d --- /dev/null +++ b/hub/server/routes/publishing.test.ts @@ -0,0 +1,313 @@ +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 { maximumPublishingJsonBytes } from "#server/domains/artifact-publishing/create-service/constants" +import type { ArtifactPublishingService } from "#server/domains/artifact-publishing/create-service/types" +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 { createApp } from "../create-app/index.ts" + +function runtime( + overrides: Partial> = {}, +): AuthenticationRoutes & { artifactPublishing: Mocked } { + const artifactPublishing = Object.assign( + { + publish: vi.fn(), + getStatus: vi.fn(), + promote: vi.fn(), + }, + overrides, + ) as Mocked + return { + service: {} as Mocked, + administration: {} as Mocked, + prototypeAuthorization: {} as Mocked, + artifactPublishing, + 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}` +} + +function expectPublishingPrivacyHeaders(response: Response) { + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") +} + +describe("publishing routes", () => { + it("passes workload authorization and idempotency to publication without browser state", async () => { + const publish = vi.fn().mockResolvedValue({ + version: { id: "version-id" }, + replayed: false, + promotedChannel: null, + }) + const authentication = runtime({ + publish, + }) + const baseUrl = await serve(authentication) + const body = { + manifest: { schemaVersion: 1 }, + artifactBase64: "cHJpdmF0ZS1hcnRpZmFjdA==", + } + + const response = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "Bearer workload-token", + "content-type": "application/json", + "idempotency-key": "request-key", + }, + body: JSON.stringify(body), + }) + + expect(response.status).toBe(201) + expectPublishingPrivacyHeaders(response) + expect(await response.json()).toEqual({ + version: { id: "version-id" }, + replayed: false, + promotedChannel: null, + }) + expect(publish).toHaveBeenCalledWith( + "Bearer workload-token", + "prototype-id", + "request-key", + body, + ) + }) + + it("returns 200 for an idempotent publication replay", async () => { + const authentication = runtime({ + publish: vi.fn().mockResolvedValue({ + version: { id: "same-version" }, + replayed: true, + promotedChannel: "preview", + }), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "Bearer workload-token", + "content-type": "application/json", + "idempotency-key": "retry-key", + }, + body: JSON.stringify({ manifest: {}, artifactBase64: "" }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + version: { id: "same-version" }, + replayed: true, + }) + }) + + it("routes status reads and channel promotion through workload credentials", async () => { + const getStatus = vi.fn().mockResolvedValue({ publicationId: "publication-id" }) + const promote = vi.fn().mockResolvedValue({ + name: "current", + prototypeVersionId: "version-id", + }) + const authentication = runtime({ + getStatus, + promote, + }) + const baseUrl = await serve(authentication) + const headers = { + authorization: "Bearer workload-token", + "idempotency-key": "request-key", + } + + const status = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/status`, { + headers, + }) + const promotion = await fetch( + `${baseUrl}/api/publishing/prototypes/prototype-id/channels/current`, + { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ versionId: "version-id" }), + }, + ) + + expect(status.status).toBe(200) + expect(await status.json()).toEqual({ publicationId: "publication-id" }) + expect(getStatus).toHaveBeenCalledWith("Bearer workload-token", "prototype-id", "request-key") + expect(promotion.status).toBe(200) + expect(await promotion.json()).toEqual({ + channel: { name: "current", prototypeVersionId: "version-id" }, + }) + expect(promote).toHaveBeenCalledWith("Bearer workload-token", "prototype-id", "current", { + versionId: "version-id", + }) + }) + + it("omits workload authorization, query values, and artifact content from request logs", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const authentication = runtime({ + publish: vi.fn().mockResolvedValue({ + version: { id: "version-id" }, + replayed: false, + promotedChannel: null, + }), + }) + const baseUrl = await serve(authentication, logger) + + const response = await fetch( + `${baseUrl}/api/publishing/prototypes/prototype-id/versions?note=private-query`, + { + method: "POST", + headers: { + authorization: "Bearer private-workload-token", + "content-type": "application/json", + "idempotency-key": "request-key", + }, + body: JSON.stringify({ + manifest: { provenance: { revision: "private-revision" } }, + artifactBase64: "cHJpdmF0ZS1hcnRpZmFjdC1jb250ZW50", + }), + }, + ) + + expect(response.status).toBe(201) + 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-workload-token|private-query|private-revision|cHJpdmF0ZS1hcnRpZmFjdC1jb250ZW50/, + ) + }) + + it("rejects a publishing body above its dedicated limit before the service runs", async () => { + const records: Record[] = [] + const logger = createLogger({ + destination: { + write: (line) => records.push(JSON.parse(line) as Record), + }, + }) + const publish = vi.fn() + const authentication = runtime({ publish }) + const baseUrl = await serve(authentication, logger) + const privateContent = "x".repeat(maximumPublishingJsonBytes) + + const response = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "Bearer private-workload-token", + "content-type": "application/json", + "idempotency-key": "oversized-request", + }, + body: JSON.stringify({ artifactBase64: privateContent }), + }) + + expect(response.status).toBe(413) + expectPublishingPrivacyHeaders(response) + expect(await response.json()).toEqual({ error: "Invalid request" }) + expect(publish).not.toHaveBeenCalled() + await vi.waitFor(() => expect(records).toHaveLength(1)) + expect(records[0]).not.toHaveProperty("body") + expect(JSON.stringify(records)).not.toContain(privateContent.slice(0, 100)) + }) + + it("applies publishing rate limits before parsing malformed JSON", async () => { + const getStatus = vi.fn().mockResolvedValue({ publicationId: null }) + const publish = vi.fn() + const baseUrl = await serve( + runtime({ getStatus, publish }), + createLogger({ destination: { write: () => undefined } }), + ) + + for (let request = 0; request < 60; request++) { + const response = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/status`, { + headers: { authorization: "******" }, + }) + expect(response.status).toBe(200) + } + const rejected = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "******", + "content-type": "application/json", + "idempotency-key": "rate-limited", + }, + body: "{", + }) + + expect(rejected.status).toBe(429) + expectPublishingPrivacyHeaders(rejected) + expect(await rejected.json()).toEqual({ + error: "rate_limited", + message: "Too many publishing requests.", + }) + expect(getStatus).toHaveBeenCalledTimes(60) + expect(publish).not.toHaveBeenCalled() + }) + + it("uses the larger JSON limit only for version uploads", async () => { + const publish = vi.fn().mockResolvedValue({ + version: { id: "version-id" }, + replayed: false, + promotedChannel: null, + }) + const promote = vi.fn() + const baseUrl = await serve(runtime({ publish, promote })) + const padding = "x".repeat(128 * 1024) + + const upload = await fetch(`${baseUrl}/api/publishing/prototypes/prototype-id/versions`, { + method: "POST", + headers: { + authorization: "******", + "content-type": "application/json", + "idempotency-key": "larger-parser", + }, + body: JSON.stringify({ padding }), + }) + const promotion = await fetch( + `${baseUrl}/api/publishing/prototypes/prototype-id/channels/current`, + { + method: "PUT", + headers: { + authorization: "******", + "content-type": "application/json", + }, + body: JSON.stringify({ padding }), + }, + ) + + expect(upload.status).toBe(201) + expect(publish).toHaveBeenCalledOnce() + expect(promotion.status).toBe(413) + expect(promote).not.toHaveBeenCalled() + }) +}) diff --git a/hub/server/routes/publishing/index.ts b/hub/server/routes/publishing/index.ts new file mode 100644 index 00000000..e87057e4 --- /dev/null +++ b/hub/server/routes/publishing/index.ts @@ -0,0 +1,58 @@ +import type { Request, Response } from "express" + +import type { ArtifactPublishingService } from "#server/domains/artifact-publishing/create-service/types" + +function parameter(request: Request, name: string): string { + const value = request.params[name] + return typeof value === "string" ? value : "" +} + +function authorization(request: Request): string { + return request.get("authorization") ?? "" +} + +function idempotencyKey(request: Request): string { + return request.get("idempotency-key") ?? "" +} + +export async function publishArtifact( + artifactPublishing: ArtifactPublishingService, + request: Request, + response: Response, +): Promise { + const result = await artifactPublishing.publish( + authorization(request), + parameter(request, "prototypeId"), + idempotencyKey(request), + request.body, + ) + response.status(result.replayed ? 200 : 201).json(result) +} + +export async function getPublicationStatus( + artifactPublishing: ArtifactPublishingService, + request: Request, + response: Response, +): Promise { + response.json( + await artifactPublishing.getStatus( + authorization(request), + parameter(request, "prototypeId"), + idempotencyKey(request), + ), + ) +} + +export async function promoteVersion( + artifactPublishing: ArtifactPublishingService, + request: Request, + response: Response, +): Promise { + const channel = await artifactPublishing.promote( + authorization(request), + parameter(request, "prototypeId"), + parameter(request, "channel"), + request.body, + ) + response.json({ channel }) +} diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts new file mode 100644 index 00000000..d4a86df7 --- /dev/null +++ b/hub/server/routes/types.ts @@ -0,0 +1,17 @@ +import type { + AuthenticationService, + 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" +import type { GitHubAuthorizationService } from "#server/domains/github-authorization/create-service/types" +import type { ArtifactPublishingService } from "#server/domains/artifact-publishing/create-service/types" + +export interface AuthenticationRoutes { + service: AuthenticationService + administration: AdministrationService + prototypeAuthorization: PrototypeAuthorizationService + githubAuthorization?: GitHubAuthorizationService + artifactPublishing?: ArtifactPublishingService + settings: AuthenticationSettings +} diff --git a/hub/server/utils/authentication-cookies/index.ts b/hub/server/utils/authentication-cookies/index.ts new file mode 100644 index 00000000..9faf37e6 --- /dev/null +++ b/hub/server/utils/authentication-cookies/index.ts @@ -0,0 +1,20 @@ +export function authenticationCookies(secure: boolean) { + const prefix = secure ? "__Host-" : "" + return { + session: `${prefix}replica-session`, + authorization: `${prefix}replica-authorization`, + csrf: `${prefix}replica-csrf`, + privateOptions: { + httpOnly: true, + secure, + sameSite: "lax", + path: "/", + } as const, + csrfOptions: { + httpOnly: false, + secure, + sameSite: "lax", + path: "/", + } as const, + } +} diff --git a/hub/server/utils/authentication-error/index.ts b/hub/server/utils/authentication-error/index.ts new file mode 100644 index 00000000..456811d5 --- /dev/null +++ b/hub/server/utils/authentication-error/index.ts @@ -0,0 +1,9 @@ +export class AuthenticationError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message) + } +} diff --git a/hub/server/utils/digest-secret/index.ts b/hub/server/utils/digest-secret/index.ts new file mode 100644 index 00000000..19742d88 --- /dev/null +++ b/hub/server/utils/digest-secret/index.ts @@ -0,0 +1,5 @@ +import { createHash } from "node:crypto" + +export function digestSecret(value: string): string { + return createHash("sha256").update(value).digest("base64url") +} diff --git a/hub/server/utils/logging/sanitize-log-value/constants.ts b/hub/server/utils/logging/sanitize-log-value/constants.ts index d15e3596..ce7fab5a 100644 --- a/hub/server/utils/logging/sanitize-log-value/constants.ts +++ b/hub/server/utils/logging/sanitize-log-value/constants.ts @@ -6,3 +6,5 @@ export const maxLogKeyLength = 128 export const truncationMarker = "[TRUNCATED]" export const sensitiveLogKeyPattern = /(password|passwd|passphrase|secret|token|authorization|cookie|apikey|privatekey|sessionid)/ +export const sensitiveRequestLogKeyPattern = + /(?:password|passwd|passphrase|secret|token|authorization|cookie|apikey|privatekey|sessionid|^(?:code|state|nonce|codeverifier)$)/ diff --git a/hub/server/utils/logging/sanitize-log-value/index.test.ts b/hub/server/utils/logging/sanitize-log-value/index.test.ts index ecb60b54..1e623fbf 100644 --- a/hub/server/utils/logging/sanitize-log-value/index.test.ts +++ b/hub/server/utils/logging/sanitize-log-value/index.test.ts @@ -25,6 +25,24 @@ describe("sanitizeLogValue()", () => { expect(input).toEqual(original) }) + it("keeps callback-shaped fields in general application log data", () => { + const input = { + code: "application-code", + STATE: "application-state", + "no-nce": "application-nonce", + code_verifier: "application-verifier", + access_token: "application-token", + } + + expect(sanitizeLogValue(input)).toEqual({ + code: "application-code", + STATE: "application-state", + "no-nce": "application-nonce", + code_verifier: "application-verifier", + access_token: "[REDACTED]", + }) + }) + it("makes cycles and non-JSON primitives safe to serialize", () => { const input: Record = { title: "Keep this", diff --git a/hub/server/utils/logging/sanitize-log-value/index.ts b/hub/server/utils/logging/sanitize-log-value/index.ts index fd81e426..aa9960cc 100644 --- a/hub/server/utils/logging/sanitize-log-value/index.ts +++ b/hub/server/utils/logging/sanitize-log-value/index.ts @@ -2,11 +2,18 @@ import { Buffer } from "node:buffer" import { visitLogValue } from "../visit-log-value/index.ts" -import { maxLogBytes } from "./constants.ts" +import { maxLogBytes, sensitiveLogKeyPattern } from "./constants.ts" import type { LogValue } from "./types.ts" /** Copy structured data with secret-key redaction and bounded JSON output. */ -export function sanitizeLogValue(value: unknown): LogValue { - const result = visitLogValue(value, 0, { ancestors: new WeakSet(), entries: 0 }) +export function sanitizeLogValue( + value: unknown, + sensitiveKeyPattern: RegExp = sensitiveLogKeyPattern, +): LogValue { + const result = visitLogValue(value, 0, { + ancestors: new WeakSet(), + entries: 0, + sensitiveKeyPattern, + }) return Buffer.byteLength(JSON.stringify(result)) <= maxLogBytes ? result : "[OMITTED: MAX BYTES]" } diff --git a/hub/server/utils/logging/visit-log-value/index.ts b/hub/server/utils/logging/visit-log-value/index.ts index 247eb881..902cccff 100644 --- a/hub/server/utils/logging/visit-log-value/index.ts +++ b/hub/server/utils/logging/visit-log-value/index.ts @@ -3,7 +3,6 @@ import { maxLogEntries, maxLogKeyLength, maxLogStringLength, - sensitiveLogKeyPattern, truncationMarker, } from "../sanitize-log-value/constants.ts" import type { LogValue } from "../sanitize-log-value/types.ts" @@ -45,7 +44,7 @@ export function visitLogValue(input: unknown, depth: number, traversal: LogTrave const descriptor = Object.getOwnPropertyDescriptor(input, key) if (!descriptor) continue let result: LogValue - if (sensitiveLogKeyPattern.test(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) { + if (traversal.sensitiveKeyPattern.test(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) { result = "[REDACTED]" } else if ("value" in descriptor) { result = visitLogValue(descriptor.value, depth + 1, traversal) diff --git a/hub/server/utils/logging/visit-log-value/types.ts b/hub/server/utils/logging/visit-log-value/types.ts index 9446a8f0..bb3c8573 100644 --- a/hub/server/utils/logging/visit-log-value/types.ts +++ b/hub/server/utils/logging/visit-log-value/types.ts @@ -1,4 +1,5 @@ export interface LogTraversal { ancestors: WeakSet entries: number + sensitiveKeyPattern: RegExp } diff --git a/hub/server/utils/normalize-entra-issuer/index.ts b/hub/server/utils/normalize-entra-issuer/index.ts new file mode 100644 index 00000000..b68b8fc0 --- /dev/null +++ b/hub/server/utils/normalize-entra-issuer/index.ts @@ -0,0 +1,8 @@ +const entraIssuerPattern = + /^https:\/\/login\.microsoftonline\.com\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/v2\.0\/?$/i + +export function normalizeEntraIssuer(value: string): string | null { + const match = value.match(entraIssuerPattern) + const tenantId = match?.[1] + return tenantId ? `https://login.microsoftonline.com/${tenantId.toLowerCase()}/v2.0` : null +} diff --git a/hub/server/utils/read-cookie/index.ts b/hub/server/utils/read-cookie/index.ts new file mode 100644 index 00000000..56974d8d --- /dev/null +++ b/hub/server/utils/read-cookie/index.ts @@ -0,0 +1,8 @@ +export function readCookie(header: string | undefined, name: string): string { + const values = (header ?? "") + .split(";") + .map((part) => part.trim()) + .filter((part) => part.startsWith(`${name}=`)) + const value = values.length === 1 ? (values[0]?.slice(name.length + 1) ?? "") : "" + return /^[\w-]{43}$/.test(value) ? value : "" +} diff --git a/hub/server/utils/require-csrf-token/index.ts b/hub/server/utils/require-csrf-token/index.ts new file mode 100644 index 00000000..5ace370e --- /dev/null +++ b/hub/server/utils/require-csrf-token/index.ts @@ -0,0 +1,8 @@ +import { AuthenticationError } from "#server/utils/authentication-error" +import { secretsMatch } from "#server/utils/secrets-match" + +export function requireCsrfToken(cookieToken: string, headerToken: string): void { + if (!cookieToken || !secretsMatch(cookieToken, headerToken)) { + throw new AuthenticationError(403, "csrf_failed", "The CSRF token did not match.") + } +} diff --git a/hub/server/utils/secrets-match/index.ts b/hub/server/utils/secrets-match/index.ts new file mode 100644 index 00000000..24ad8b46 --- /dev/null +++ b/hub/server/utils/secrets-match/index.ts @@ -0,0 +1,7 @@ +import { timingSafeEqual } from "node:crypto" + +export function secretsMatch(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left) + const rightBuffer = Buffer.from(right) + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer) +} diff --git a/hub/test/devcontainer-config.test.ts b/hub/test/devcontainer-config.test.ts new file mode 100644 index 00000000..4c821722 --- /dev/null +++ b/hub/test/devcontainer-config.test.ts @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises" + +import { describe, expect, it } from "vitest" + +function mappingBlock(source: string, key: string, indentation: number) { + const lines = source.split("\n") + const start = lines.findIndex((line) => line === `${" ".repeat(indentation)}${key}:`) + const block: string[] = [] + + if (start < 0) return "" + + for (const line of lines.slice(start + 1)) { + if (line.trim() !== "" && line.search(/\S/) <= indentation) break + block.push(line) + } + + return block.join("\n") +} + +describe("Hub devcontainer", () => { + it("defaults to GitHub while allowing hub/.env to select OIDC", async () => { + const compose = await readFile( + new URL("../.devcontainer/compose.yaml", import.meta.url), + "utf8", + ) + const defaults = await readFile( + new URL("../.devcontainer/default.env", import.meta.url), + "utf8", + ) + const appService = mappingBlock(compose, "app", 2) + const environmentFiles = mappingBlock(appService, "env_file", 4) + + expect(defaults.trim()).toBe("HUB_AUTH_PROVIDER=github") + expect(environmentFiles).toMatch(/- path: default\.env[\s\S]*- path: \.\.\/\.env/) + expect(environmentFiles).toMatch(/path: \.\.\/\.env[\s\S]*required: false/) + expect(mappingBlock(appService, "environment", 4)).not.toContain("HUB_AUTH_PROVIDER") + }) +}) diff --git a/hub/tsconfig.json b/hub/tsconfig.json index 516108e7..fe11015c 100644 --- a/hub/tsconfig.json +++ b/hub/tsconfig.json @@ -8,6 +8,7 @@ "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "customConditions": ["development"], "noEmit": true, "noUncheckedIndexedAccess": true, "paths": { diff --git a/hub/tsconfig.server.json b/hub/tsconfig.server.json index ebd6babc..6c5d0765 100644 --- a/hub/tsconfig.server.json +++ b/hub/tsconfig.server.json @@ -12,5 +12,5 @@ "lib": ["ES2023"] }, "include": ["server/**/*.ts"], - "exclude": ["server/**/*.test.ts"] + "exclude": ["server/**/*.test.ts", "server/**/test/**"] } diff --git a/scripts/check-ci-workflows/index.test.ts b/scripts/check-ci-workflows/index.test.ts index 070d3d45..9d3e5d13 100644 --- a/scripts/check-ci-workflows/index.test.ts +++ b/scripts/check-ci-workflows/index.test.ts @@ -33,6 +33,21 @@ describe("checkWorkflows()", () => { expect(() => checkWorkflows(root)).not.toThrow() }) + it("runs the Entra setup contract through the Hub package command on both Node versions", () => { + const workflow = parse( + readFileSync(join(root, ".github/workflows/hub-ci.yml"), "utf8"), + ) as Workflow + const manifest = JSON.parse(readFileSync(join(root, "hub/package.json"), "utf8")) + + expect(manifest.scripts["test:entra-setup"]).toBe( + "node --test script/tests/setup-entra-oidc.test.mjs", + ) + expect(workflow.jobs.check.strategy?.matrix.command).toContain("test:entra-setup") + expect(workflow.jobs.check.steps).toContainEqual( + expect.objectContaining({ run: "npm run ${{ matrix.command }}" }), + ) + }) + it("rejects an aggregate result gate", () => { const directory = fixture() const path = join(directory, ".github/workflows/hub-ci.yml") diff --git a/scripts/check-ci-workflows/index.ts b/scripts/check-ci-workflows/index.ts index 3189987d..67ea26dc 100644 --- a/scripts/check-ci-workflows/index.ts +++ b/scripts/check-ci-workflows/index.ts @@ -68,6 +68,7 @@ export function checkWorkflows(root: string): void { "lint", "typecheck", "test", + "test:entra-setup", "build", "storybook:build", "db:validate",