diff --git a/hub/.env.example b/hub/.env.example index 5e7ea9fb..38890184 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -28,6 +28,11 @@ TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres # GitHub proves identity by default. Select legacy-repository only for compatibility. 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 diff --git a/hub/README.md b/hub/README.md index 56dcb98f..84fda01e 100644 --- a/hub/README.md +++ b/hub/README.md @@ -2,9 +2,9 @@ Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub. -The API exposes its health check and GitHub authentication and session routes. The frontend provides the minimal GitHub sign-in and sign-out surface with light and dark themes. Existing components, features, and pages remain in Storybook, but they are not connected to product APIs. +The API exposes its health check, GitHub authentication, sessions, and installation membership administration. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs. -Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. GitHub sign-in works when its dedicated client credentials are configured. The database has no product models; invitations, membership administration, product authorization, saved product data, live updates, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, invitations, membership roles and lifecycle, administrator recovery, and session revocation are implemented. Prototype authorization, saved prototype data, live updates, product jobs, and deployment are not implemented. 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 index d22bfe3e..36a1c186 100644 --- a/hub/app/javascript/application/authentication/index.test.tsx +++ b/hub/app/javascript/application/authentication/index.test.tsx @@ -1,3 +1,4 @@ +import { ThemeProvider } from "@primer/react-brand" import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" @@ -19,6 +20,55 @@ function sessionResponse( ) } +const administratorSession = { + authenticated: true, + configured: true, + csrfToken: "csrf-token", + user: { + id: "administrator", + provider: "github", + username: "administrator", + displayName: "Administrator", + avatarUrl: null, + }, + membership: { + id: "administrator-membership", + role: "administrator", + status: "active", + }, +} + +const administration = { + memberships: { + memberships: [], + nextCursor: null, + }, + invitations: { + invitations: [ + { + id: "invitation", + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + expiresAt: "2026-11-05T18:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + nextCursor: null, + }, +} + +function renderApplication(fetch: ReturnType) { + vi.stubGlobal("fetch", fetch) + render( + + + , + ) +} + describe("AuthenticationApplication()", () => { it("shows a generic callback error and removes its query marker", async () => { window.history.replaceState({}, "", "/?authentication=failed") @@ -70,4 +120,249 @@ describe("AuthenticationApplication()", () => { ) expect(fetchMock).toHaveBeenCalledTimes(2) }) + + it("loads and appends later membership and invitation pages from their cursors", async () => { + const firstPage = { + memberships: { + memberships: [ + { + id: "first-membership", + status: "active", + role: "member", + user: { + id: "first-user", + displayName: "First member", + externalIdentities: [], + }, + }, + ], + nextCursor: "membership-cursor", + }, + invitations: { + invitations: [ + { + id: "first-invitation", + provider: "github", + issuer: "https://github.com", + subject: "first-subject", + role: "member", + expiresAt: "2026-11-05T18:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + nextCursor: "invitation-cursor", + }, + } + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(firstPage)) + .mockResolvedValueOnce( + Response.json({ + memberships: { + memberships: [ + { + id: "second-membership", + status: "suspended", + role: "member", + user: { + id: "second-user", + displayName: "Second member", + externalIdentities: [], + }, + }, + ], + nextCursor: null, + }, + invitations: firstPage.invitations, + }), + ) + .mockResolvedValueOnce( + Response.json({ + memberships: firstPage.memberships, + invitations: { + invitations: [ + { + id: "second-invitation", + provider: "github", + issuer: "https://github.com", + subject: "second-subject", + role: "member", + expiresAt: "2026-11-06T18:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + nextCursor: null, + }, + }), + ) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Load more memberships" })) + expect(await screen.findByText("Second member")).toBeInTheDocument() + expect(screen.getByText("First member")).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith("/api/administration?membershipsCursor=membership-cursor", { + credentials: "same-origin", + }) + + fireEvent.click(screen.getByRole("button", { name: "Load more invitations" })) + expect(await screen.findByText("second-subject")).toBeInTheDocument() + expect(screen.getByText("first-subject")).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith("/api/administration?invitationsCursor=invitation-cursor", { + credentials: "same-origin", + }) + expect( + screen.queryByRole("button", { name: /^Load more (memberships|invitations)$/ }), + ).not.toBeInTheDocument() + }) + + it.each([ + { + name: "an expired session", + status: 401, + error: "sign_in_required", + message: "Sign in to continue.", + }, + { + name: "lost administrator access", + status: 403, + error: "administrator_required", + message: "Administrator access is required.", + }, + ])("removes stale controls when loading more detects $name", async (failure) => { + const firstPage = { + memberships: { + memberships: [], + nextCursor: "membership-cursor", + }, + invitations: { + invitations: [], + nextCursor: null, + }, + } + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(firstPage)) + .mockResolvedValueOnce( + Response.json( + { error: failure.error, message: failure.message }, + { status: failure.status }, + ), + ) + .mockResolvedValueOnce( + Response.json({ + authenticated: false, + configured: true, + csrfToken: "replacement-csrf-token", + }), + ) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Load more memberships" })) + + expect(await screen.findByRole("alert")).toHaveTextContent(failure.message) + expect(screen.queryByRole("heading", { name: "Hub membership" })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Load more memberships" })).not.toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith("/api/session", { credentials: "same-origin" }) + }) + + it("starts same-identity verification when an administration mutation needs fresh authorization", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(administration)) + .mockResolvedValueOnce( + Response.json( + { + error: "fresh_authorization_required", + message: "Verify your identity again.", + }, + { status: 403 }, + ), + ) + .mockResolvedValueOnce(Response.json({ authorizationUrl: "https://github.example/verify" })) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" })) + + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + "/api/session/verify", + expect.objectContaining({ + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": "csrf-token" }, + }), + ), + ) + expect(screen.queryByText("fresh_authorization_required")).not.toBeInTheDocument() + }) + + it("shows the server-readable message for other administration failures", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(administration)) + .mockResolvedValueOnce( + Response.json( + { + error: "invitation_changed", + message: "The invitation is no longer available.", + }, + { status: 409 }, + ), + ) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" })) + + expect(await screen.findByRole("alert")).toHaveTextContent( + "The invitation is no longer available.", + ) + expect(screen.getByRole("alert")).not.toHaveTextContent("invitation_changed") + }) + + it.each([ + { + name: "an expired session", + status: 401, + error: "sign_in_required", + message: "Sign in to continue.", + }, + { + name: "lost administrator access", + status: 403, + error: "administrator_required", + message: "Administrator access is required.", + }, + ])("reloads session state and removes stale controls after $name", async (failure) => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(administratorSession)) + .mockResolvedValueOnce(Response.json(administration)) + .mockResolvedValueOnce( + Response.json( + { error: failure.error, message: failure.message }, + { status: failure.status }, + ), + ) + .mockResolvedValueOnce( + Response.json({ + authenticated: false, + configured: true, + csrfToken: "replacement-csrf-token", + }), + ) + renderApplication(fetch) + + fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" })) + + expect(await screen.findByRole("alert")).toHaveTextContent(failure.message) + expect(screen.queryByRole("heading", { name: "Hub membership" })).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Revoke invitation" })).not.toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith("/api/session", { credentials: "same-origin" }) + }) }) diff --git a/hub/app/javascript/application/authentication/index.tsx b/hub/app/javascript/application/authentication/index.tsx index bc548e0c..ae26434b 100644 --- a/hub/app/javascript/application/authentication/index.tsx +++ b/hub/app/javascript/application/authentication/index.tsx @@ -2,12 +2,48 @@ 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 csrfToken: string user?: SignInPageUser + membership?: { + id: string + role: "member" | "administrator" + status: "active" + } +} + +interface AdministrationResponse { + memberships: { + memberships: MembershipRecord[] + nextCursor: string | null + } + invitations: { + invitations: InvitationRecord[] + nextCursor: string | null + } +} + +interface AdministrationErrorResponse { + error?: string + message?: string +} + +class AdministrationRequestError extends Error { + constructor( + readonly status: number, + readonly code: string | undefined, + message: string, + ) { + super(message) + } } async function fetchSession(): Promise { @@ -16,6 +52,27 @@ async function fetchSession(): Promise { return (await response.json()) as SessionResponse } +async function fetchAdministration(cursors?: { + memberships?: string | null + invitations?: string | null +}): Promise { + const query = new URLSearchParams() + if (cursors?.memberships) query.set("membershipsCursor", cursors.memberships) + if (cursors?.invitations) query.set("invitationsCursor", cursors.invitations) + const response = await fetch(`/api/administration${query.size ? `?${query}` : ""}`, { + credentials: "same-origin", + }) + if (!response.ok) { + const data = (await response.json()) as AdministrationErrorResponse + throw new AdministrationRequestError( + response.status, + data.error, + data.message ?? "Administration request failed", + ) + } + return (await response.json()) as AdministrationResponse +} + export function AuthenticationApplication() { const [session, setSession] = useState(null) const [error, setError] = useState(() => @@ -24,6 +81,7 @@ export function AuthenticationApplication() { : null, ) const [loading, setLoading] = useState(true) + const [administration, setAdministration] = useState(null) useEffect(() => { const url = new URL(window.location.href) @@ -32,7 +90,12 @@ export function AuthenticationApplication() { window.history.replaceState(window.history.state, "", url) } fetchSession() - .then(setSession) + .then(async (value) => { + setSession(value) + if (value.membership?.role === "administrator") { + setAdministration(await fetchAdministration()) + } + }) .catch(() => setError("Hub could not load your sign-in status.")) .finally(() => setLoading(false)) }, []) @@ -58,6 +121,29 @@ export function AuthenticationApplication() { } } + 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) @@ -73,6 +159,7 @@ export function AuthenticationApplication() { }) if (!response.ok) throw new Error("Sign-out request failed") setSession(await fetchSession()) + setAdministration(null) } catch { setError("Hub could not sign you out.") } finally { @@ -80,6 +167,136 @@ export function AuthenticationApplication() { } } + 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 AdministrationErrorResponse + if (data.error === "fresh_authorization_required") { + const verification = await fetch("/api/session/verify", { + method: "POST", + credentials: "same-origin", + headers: { "X-CSRF-Token": session.csrfToken }, + }) + if (!verification.ok) { + throw new Error("Hub could not start identity verification.") + } + const verificationData = (await verification.json()) as { authorizationUrl: string } + window.location.assign(verificationData.authorizationUrl) + return + } + if (response.status === 401 || data.error === "administrator_required") { + setAdministration(null) + setSession(await fetchSession()) + } + throw new Error(data.message ?? "Administration request failed") + } + const current = await fetchSession() + setSession(current) + if (current.membership?.role === "administrator") { + setAdministration(await fetchAdministration()) + } else { + setAdministration(null) + } + } catch (requestError) { + setError( + requestError instanceof Error ? requestError.message : "Hub could not update membership.", + ) + } finally { + setLoading(false) + } + } + + async function loadMoreAdministration(collection: "memberships" | "invitations") { + if (!administration) return + const cursor = + collection === "memberships" + ? administration.memberships.nextCursor + : administration.invitations.nextCursor + if (!cursor) return + setLoading(true) + setError(null) + try { + const page = await fetchAdministration({ [collection]: cursor }) + setAdministration((current) => { + if (!current) return current + if (collection === "memberships") { + return { + ...current, + memberships: { + memberships: [...current.memberships.memberships, ...page.memberships.memberships], + nextCursor: page.memberships.nextCursor, + }, + } + } + return { + ...current, + invitations: { + invitations: [...current.invitations.invitations, ...page.invitations.invitations], + nextCursor: page.invitations.nextCursor, + }, + } + }) + } catch (requestError) { + if ( + requestError instanceof AdministrationRequestError && + (requestError.status === 401 || requestError.code === "administrator_required") + ) { + setAdministration(null) + try { + setSession(await fetchSession()) + } catch { + setSession(null) + } + setError(requestError.message) + } else { + setError("Hub could not load more administration records.") + } + } 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") + } + onLoadMoreMemberships={() => void loadMoreAdministration("memberships")} + onLoadMoreInvitations={() => void loadMoreAdministration("invitations")} + 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/pages/membership-administration-page/index.test.tsx b/hub/app/javascript/pages/membership-administration-page/index.test.tsx new file mode 100644 index 00000000..fa7cf64b --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.test.tsx @@ -0,0 +1,172 @@ +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("defaults new invitations to the member role", () => { + const onInvite = vi.fn() + render( + + + , + ) + + fireEvent.change(screen.getByRole("textbox", { name: "Immutable subject" }), { + target: { value: "42" }, + }) + fireEvent.submit(screen.getByRole("button", { name: "Create invitation" }).closest("form")!) + + expect(onInvite).toHaveBeenCalledWith({ + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + }) + }) + + 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() + }) + + it("distinguishes usable, expired, and revoked invitation history", () => { + const onRevokeInvitation = vi.fn() + render( + + + , + ) + + const usable = screen.getByText("usable-subject").closest("li")! + fireEvent.click(within(usable).getByRole("button", { name: "Revoke invitation" })) + expect(onRevokeInvitation).toHaveBeenCalledWith("usable") + + for (const subject of ["expired-subject", "cleanup-expired-subject"]) { + const invitation = screen.getByText(subject).closest("li")! + expect(within(invitation).getByText("Expired")).toBeInTheDocument() + expect( + within(invitation).queryByRole("button", { name: "Revoke invitation" }), + ).not.toBeInTheDocument() + } + + const revoked = screen.getByText("revoked-subject").closest("li")! + expect(within(revoked).getByText("Revoked")).toBeInTheDocument() + expect( + within(revoked).queryByRole("button", { name: "Revoke invitation" }), + ).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..83a3aaa5 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.tsx @@ -0,0 +1,253 @@ +import { useEffect, 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, + hasMoreMemberships, + hasMoreInvitations, + loading, + error, + onInvite, + onChange, + onRevokeInvitation, + onLoadMoreMemberships, + onLoadMoreInvitations, + onSignOut, +}: MembershipAdministrationPageProps) { + const [provider, setProvider] = useState("github") + const [issuer, setIssuer] = useState("https://github.com") + const [subject, setSubject] = useState("") + const [role, setRole] = useState<"member" | "administrator">("member") + const [currentTime, setCurrentTime] = useState(null) + + useEffect(() => { + function updateCurrentTime() { + setCurrentTime(Date.now()) + } + updateCurrentTime() + const timer = window.setInterval(updateCurrentTime, 60_000) + return () => window.clearInterval(timer) + }, []) + + 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} +
    +
  • + ) + })} +
+ {hasMoreMemberships ? ( + + ) : null} +
+ +
+ + Invitations + +
    + {invitations.map((invitation) => { + const expiresAt = Date.parse(invitation.expiresAt) + const revokedAt = invitation.revokedAt ? Date.parse(invitation.revokedAt) : null + const expired = currentTime !== null && expiresAt <= currentTime + const manuallyRevoked = revokedAt !== null && revokedAt < expiresAt + const usable = !invitation.consumedAt && !invitation.revokedAt && !expired + const state = invitation.consumedAt + ? "Activated" + : manuallyRevoked + ? "Revoked" + : expired || invitation.revokedAt + ? "Expired" + : null + return ( +
  • + {invitation.subject} + + {invitation.provider} · {invitation.issuer} · {invitation.role} + + {usable ? ( + + ) : ( + {state} + )} +
  • + ) + })} +
+ {hasMoreInvitations ? ( + + ) : null} +
+
+ ) +} 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..df80b212 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/stories.tsx @@ -0,0 +1,119 @@ +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, + }, + ], + hasMoreMemberships: false, + hasMoreInvitations: false, + onInvite: () => undefined, + onChange: () => undefined, + onRevokeInvitation: () => undefined, + onLoadMoreMemberships: () => undefined, + onLoadMoreInvitations: () => undefined, + onSignOut: () => undefined, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} +export const MoreRecordsAvailable: Story = { + args: { + hasMoreMemberships: true, + hasMoreInvitations: true, + }, +} + +export const InvitationHistory: Story = { + args: { + invitations: [ + { + id: "invitation-activated", + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + expiresAt: "2026-11-03T20:00:00.000Z", + consumedAt: "2026-10-28T20:00:00.000Z", + revokedAt: null, + }, + { + id: "invitation-revoked", + provider: "github", + issuer: "https://github.com", + subject: "84", + role: "administrator", + expiresAt: "2026-11-03T20:00:00.000Z", + consumedAt: null, + revokedAt: "2026-10-28T20:00:00.000Z", + }, + { + id: "invitation-expired", + provider: "github", + issuer: "https://github.com", + subject: "126", + role: "member", + expiresAt: "2026-10-27T20:00:00.000Z", + consumedAt: null, + revokedAt: "2026-10-28T20:00:00.000Z", + }, + ], + }, +} 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..56fe5d45 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/styles.css @@ -0,0 +1,49 @@ +.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); +} + +.membership-administration-page__danger:not(:disabled) { + 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..7623ba7a --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/types.ts @@ -0,0 +1,52 @@ +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[] + hasMoreMemberships: boolean + hasMoreInvitations: boolean + 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 + onLoadMoreMemberships: () => void + onLoadMoreInvitations: () => 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 index d2d9ea47..b16bd1bd 100644 --- a/hub/app/javascript/pages/sign-in-page/index.test.tsx +++ b/hub/app/javascript/pages/sign-in-page/index.test.tsx @@ -14,6 +14,8 @@ describe("SignInPage()", () => { error={null} onSignIn={onSignIn} onSignOut={() => undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} />, ) @@ -32,6 +34,8 @@ describe("SignInPage()", () => { error={null} onSignIn={() => undefined} onSignOut={() => undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} />, ) @@ -48,6 +52,8 @@ describe("SignInPage()", () => { error={null} onSignIn={() => undefined} onSignOut={() => undefined} + onBootstrap={() => undefined} + onRecovery={() => undefined} />, ) diff --git a/hub/app/javascript/pages/sign-in-page/index.tsx b/hub/app/javascript/pages/sign-in-page/index.tsx index 15907c79..53b8fd7c 100644 --- a/hub/app/javascript/pages/sign-in-page/index.tsx +++ b/hub/app/javascript/pages/sign-in-page/index.tsx @@ -1,4 +1,6 @@ -import { Button, Heading, Stack, Text } from "@primer/react-brand" +import { useState } from "react" + +import { Button, FormControl, Heading, Stack, Text, TextInput } from "@primer/react-brand" import { ReplicaLogo } from "@components/replica-logo" @@ -12,7 +14,11 @@ export function SignInPage({ error, onSignIn, onSignOut, + onBootstrap, + onRecovery, }: SignInPageProps) { + const [bootstrapSecret, setBootstrapSecret] = useState("") + const [recoverySecret, setRecoverySecret] = useState("") return (
@@ -47,6 +53,50 @@ export function SignInPage({ {!configured && ( GitHub 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 && ( diff --git a/hub/app/javascript/pages/sign-in-page/stories.tsx b/hub/app/javascript/pages/sign-in-page/stories.tsx index 2bca845f..a8eace7a 100644 --- a/hub/app/javascript/pages/sign-in-page/stories.tsx +++ b/hub/app/javascript/pages/sign-in-page/stories.tsx @@ -13,6 +13,8 @@ const meta = { error: null, onSignIn: () => undefined, onSignOut: () => undefined, + onBootstrap: () => undefined, + onRecovery: () => undefined, }, } satisfies Meta diff --git a/hub/app/javascript/pages/sign-in-page/styles.css b/hub/app/javascript/pages/sign-in-page/styles.css index 77bb16d8..e0632f58 100644 --- a/hub/app/javascript/pages/sign-in-page/styles.css +++ b/hub/app/javascript/pages/sign-in-page/styles.css @@ -13,3 +13,27 @@ .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 index d12b098c..e32f4081 100644 --- a/hub/app/javascript/pages/sign-in-page/types.ts +++ b/hub/app/javascript/pages/sign-in-page/types.ts @@ -11,4 +11,6 @@ export interface SignInPageProps { 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 9b6505d8..8586fc1f 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -8,6 +8,7 @@ Hub is being rebuilt. The [Hub overview](../README.md) describes what the baseli - [Development](development.md): local API and frontend setup. - [Database](database.md): Prisma schema conventions, migrations, and the shared client. - [Identity](identity.md): GitHub user authentication, provider-neutral sessions, admission policy, and source-integration credentials. +- [Membership and administration](membership-and-administration.md): Hub admission, invitations, roles, lifecycle, bootstrap, and recovery. - [Logging](logging.md): application events, automatic request records, and payload safety. - [Background jobs](background-jobs.md): PostgreSQL queues, workers, schedules, setup, and test isolation. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index a0d3ed6a..9f73c369 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -6,15 +6,15 @@ Hub contributors are rebuilding the app on `build/hub/new` before merging it int The team has chosen TypeScript, Express for the API, React and Vite for the frontend, and Vitest for tests. The old backend and the frontend code that connected to it have been removed rather than rewritten. -The API responds to a health check and provides GitHub authentication and Hub session routes. The frontend displays the sign-in state with light and dark themes. Storybook keeps the existing components and pages available for reuse. +The API responds to a health check and provides GitHub authentication, Hub sessions, and installation membership administration. The frontend displays sign-in, administrator enrollment and recovery, and a minimal membership administration page with light and dark themes. Storybook keeps the existing components and pages available for reuse. -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 provider-neutral Hub sessions work when user-auth credentials are configured. The [background-job foundation](background-jobs.md) provides PostgreSQL-backed queues and workers without product jobs. Deployment and product APIs have not been built. This app cannot yet evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [identity system](identity.md) separates Hub users, external identities, installation membership, authentication-provider configuration, sessions, authorization attempts, security records, and GitHub source-integration credentials. GitHub user authorization, Hub-owned admission, provider-neutral sessions, invitations, membership lifecycle, and administrator recovery work when user-auth credentials are configured. The [background-job foundation](background-jobs.md) provides PostgreSQL-backed queues and workers without product jobs. Deployment and prototype APIs have not been built. This app cannot yet evaluate prototype access, find repositories, import Builds, host previews, save product data, or support reviews. ## Backend API code lives under `server/`. As features are added, route handlers will read requests, call domains, and return responses. The [code conventions](backend-architecture.md) explain repositories, integrations, utilities, domains, and which layers may import others. Put these directories under `server/` when they are needed; do not add empty implementations. -`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. 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 runtime and starts the API at a local address. +`GET /api/health` returns `{ "status": "ok" }`. Authentication routes start and complete GitHub authorization, restore or revoke Hub sessions, and perform fresh verification. Administration routes list memberships and invitations, create or revoke invitations, and change membership roles or lifecycle. Unknown paths return a JSON 404 response; invalid JSON is rejected before routing. `createApp()` builds the Express app without starting a server. `server/index.ts` composes the authentication and administration runtime and starts the API at a local address. 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. @@ -37,7 +37,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 GitHub sign-in and session state 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 GitHub 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/development.md b/hub/docs/development.md index d7b9be1b..d88de6e8 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -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. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in; leave both unset to run Hub with sign-in disabled. +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. Set `HUB_GITHUB_AUTH_CLIENT_ID` and `HUB_GITHUB_AUTH_CLIENT_SECRET` to exercise real GitHub sign-in; leave both unset 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 diff --git a/hub/docs/identity.md b/hub/docs/identity.md index 2f5a52a5..1c495ca6 100644 --- a/hub/docs/identity.md +++ b/hub/docs/identity.md @@ -1,6 +1,6 @@ # Hub identity -Hub uses GitHub user authorization to prove an external identity and creates provider-neutral browser sessions. Hub installation membership records administrative roles. Later authorization work will let Hub own prototype access independently of repository permissions. +Hub uses GitHub user authorization to prove an external identity. Hub then applies its own installation admission and membership rules before creating a provider-neutral browser session. Repository permissions do not grant installation membership. ## GitHub user authentication @@ -12,21 +12,30 @@ The callback consumes an attempt once, checks the same-browser binding and curre Hub sessions use a random opaque cookie and store only its digest. Authentication and session cookies are `HttpOnly`, `SameSite=Lax`, path-scoped, and `Secure` on HTTPS. State-changing routes require the configured origin and a matching CSRF cookie and header. Authentication routes omit request payloads from logs. -`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, and no repository permission check gates sign-in. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to require explicit collaboration on one of the first 100 private installed repositories. Each check has a ten-second total deadline. Installations that exceed that bound must use Hub-managed admission or a later synchronized authorization policy. The GitHub authentication adapter never reads installation credentials or performs repository authorization. +`HUB_ADMISSION_POLICY=hub-managed` is the default. GitHub proves identity, then Hub admits only an active member, an exact provider assignment with a usable invitation, or an authorized bootstrap or recovery enrollment. `legacy-repository` is an explicit compatibility policy that uses the separate GitHub source integration to create a member assignment after repository admission. Repository admission requires explicit collaboration on one of the first 100 private installed repositories and has a ten-second total deadline. Installations that exceed that bound must use Hub-managed admission or a later synchronized authorization policy. The GitHub authentication adapter never reads installation credentials or performs repository authorization. -Sign-out revokes the current database session. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with verification less than five minutes old may revoke all sessions; revocation also supersedes callbacks that started earlier. +Sign-out revokes the current database session. Every restored session requires an active installation membership. Suspension and removal therefore deny retained cookies immediately, and their administration operations delete the affected user's sessions. Fresh verification reuses GitHub authorization and must return the same external identity. An active Hub administrator membership with fresh verification may revoke all sessions; revocation also supersedes callbacks that started earlier. ## 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 status, role, and source for this Hub installation. -- `AuthenticationProvider` and its versioned revisions store authentication configuration without tying it to one provider. An OIDC configuration does not require GitHub source credentials. -- `Session` records the Hub user, external identity, and authentication provider revision that established it. Rotating GitHub source credentials does not change session identity context. +- `InstallationMembership` stores a user's `member` or `administrator` role and `active`, `suspended`, or terminal `removed` status for this Hub installation. +- `MembershipInvitation` targets an `ExternalIdentity` assignment by provider, issuer, and immutable subject. 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 context. -Queries are available without a lock. Mutations run through one database transaction that locks a fixed store row. Related records and audit events therefore commit or roll back together, and overlapping mutations observe committed state. +Queries are available without a lock. Mutations run through one database transaction that locks a fixed store-state row. Related membership, invitation, session, and audit changes therefore commit or roll back together. Overlapping final-administrator changes and invitation activations observe committed state. + +## 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 subject. 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). Security audit queries return at most 100 records from the previous 90 days. Each new audit write deletes records older than 90 days. Operators who require a longer security history must export records before that limit. @@ -45,6 +54,6 @@ Database credentials use AES-256-GCM with contextual additional authenticated da ## Current scope -This layer adds GitHub sign-in, session restoration, sign-out, fresh verification, global session revocation, and the minimal frontend needed to use them. It does not add invitations or membership administration, prototype authorization, GitHub permission synchronization, artifact ingestion, OIDC, account linking, SCIM, or product APIs. +This layer adds GitHub sign-in, installation admission, invitations, member and administrator lifecycle, one-use bootstrap and recovery, session restoration, sign-out, fresh verification, global and per-member session revocation, structured security records, and the minimal frontend needed to use them. It does not add prototype, repository, workspace, or artifact roles; GitHub membership synchronization; artifact ingestion; production OIDC; account linking; SCIM; or prototype APIs. Apply migrations with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md new file mode 100644 index 00000000..1a1a8cb5 --- /dev/null +++ b/hub/docs/membership-and-administration.md @@ -0,0 +1,62 @@ +# Installation membership and administration + +Hub owns installation admission after an authentication provider proves an identity. Repository access and installation membership are separate decisions. + +## 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. | +| Suspended | Cannot create or use a Hub session. An administrator may restore the membership. | +| Removed | Cannot create or use a Hub session. The retained record preserves audit linkage, and return requires a new invitation. | + +Changing a membership to suspended or removed deletes that user's sessions in the same transaction. Every protected request also reloads membership, so a stale cookie cannot preserve access. Hub prevents overlapping administrator changes from removing the final active administrator. + +## Invitations + +An invitation targets an external identity assignment: + +```text +provider + issuer + immutable subject -> ExternalIdentity -> User -> InstallationMembership +``` + +An administrator may invite a member or administrator. 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` +- `POST /api/auth/bootstrap` +- `POST /api/auth/recovery` + +The administration response returns membership and invitation pages with separate `nextCursor` values. Pass `membershipsCursor` or `invitationsCursor` to continue the corresponding collection without hiding older active records behind retained history. + +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. Denied administrator mutations roll back partial changes before Hub saves the denial reason in a separate transaction. Records never include host secrets, provider tokens, request payloads, mutable identity labels, or exception text. + +## Non-goals + +This boundary does not define prototype, repository, workspace, or artifact roles. It does not synchronize GitHub organization or repository permissions, implement production OIDC, link accounts across providers, provision users through SCIM, or ingest artifacts. diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 7c6dc5fb..493618fe 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -53,13 +53,15 @@ 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. 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 cases also cover PKCE, browser binding, provider revisions, revocation races, admission-policy separation, secure cookies, CSRF and origin checks, log omission, and transient GitHub user tokens. Job integration cases exercise real PostgreSQL queues, schedules, and database resets. +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. Job integration cases exercise real PostgreSQL queues, schedules, and database resets. + +A dedicated migration case applies the foundation migration, seeds retained lifecycle and security records, then applies the next migration. This catches upgrade behavior that an empty final schema cannot prove, including audit-outcome backfills and lifecycle constraints. 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` and BullMQ's migrations, closes those setup connections, 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 committed Prisma migrations with `prisma migrate deploy` and BullMQ's migrations, closes those setup connections, then copies the template for every worker slot. 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 and job tables across user schemas. It preserves `public._prisma_migrations` and `bullmq.migration`. 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/schema/external-identity.prisma b/hub/schema/external-identity.prisma index 908e4cfa..8c7b25df 100644 --- a/hub/schema/external-identity.prisma +++ b/hub/schema/external-identity.prisma @@ -6,6 +6,7 @@ model ExternalIdentity { // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) sessions Session[] + activatedInvitations MembershipInvitation[] @relation("ActivatedIdentity") // Attribute columns provider String diff --git a/hub/schema/identity-store-state.prisma b/hub/schema/identity-store-state.prisma index 1b5ca274..badf58f7 100644 --- a/hub/schema/identity-store-state.prisma +++ b/hub/schema/identity-store-state.prisma @@ -3,7 +3,8 @@ model IdentityStoreState { id String @id @db.Uuid // Attribute columns - sessionGeneration Int @default(0) @map("session_generation") + 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) diff --git a/hub/schema/installation-membership.prisma b/hub/schema/installation-membership.prisma index da4ea594..623d965f 100644 --- a/hub/schema/installation-membership.prisma +++ b/hub/schema/installation-membership.prisma @@ -4,12 +4,16 @@ model InstallationMembership { userId String @map("user_id") @db.Uuid // Relations - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + 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 + 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) 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/20261027210000_membership_administration/migration.sql b/hub/schema/migrations/20261027210000_membership_administration/migration.sql new file mode 100644 index 00000000..8445d0f7 --- /dev/null +++ b/hub/schema/migrations/20261027210000_membership_administration/migration.sql @@ -0,0 +1,106 @@ +-- 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 '{}'; + +UPDATE "security_audits" +SET "outcome" = 'denied' +WHERE "event" = 'authentication.admission_denied'; + +UPDATE "security_audits" +SET "outcome" = 'failed' +WHERE "event" = 'authentication.provider_failed'; + +-- 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/security-audit.prisma b/hub/schema/security-audit.prisma index 8d46e5e8..e3bcd5ab 100644 --- a/hub/schema/security-audit.prisma +++ b/hub/schema/security-audit.prisma @@ -4,14 +4,24 @@ model SecurityAudit { 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 + + // Relations + targetMembership InstallationMembership? @relation("TargetMembership", fields: [targetMembershipId], references: [id], onDelete: SetNull) + invitation MembershipInvitation? @relation(fields: [invitationId], references: [id], onDelete: SetNull) // Attribute columns - event String + 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]) @@map("security_audits") } diff --git a/hub/schema/user.prisma b/hub/schema/user.prisma index 73ed59e8..8ff2cd32 100644 --- a/hub/schema/user.prisma +++ b/hub/schema/user.prisma @@ -6,6 +6,7 @@ model User { externalIdentities ExternalIdentity[] memberships InstallationMembership[] sessions Session[] + createdInvitations MembershipInvitation[] @relation("InvitationCreator") // Attribute columns displayName String? @map("display_name") diff --git a/hub/server/create-app/index.ts b/hub/server/create-app/index.ts index ef54e6b7..a78512b8 100644 --- a/hub/server/create-app/index.ts +++ b/hub/server/create-app/index.ts @@ -17,7 +17,7 @@ export function createApp({ app.disable("x-powered-by") app.set("trust proxy", authentication?.settings.trustedProxyHops ?? 0) app.use(createRequestLogger(logger)) - app.use(["/api/auth", "/api/session"], omitRequestLogPayload) + app.use(["/api/auth", "/api/session", "/api/administration"], omitRequestLogPayload) app.use(express.json()) routes(app, authentication) diff --git a/hub/server/domains/administration/create-service/index.db.test.ts b/hub/server/domains/administration/create-service/index.db.test.ts new file mode 100644 index 00000000..3eed6153 --- /dev/null +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -0,0 +1,467 @@ +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 + +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" + freshAuthorizedAt?: Date | null + } = {}, +) { + const authorizedAt = new Date("2026-10-27T18:00:00.000Z") + const role = options.role ?? "administrator" + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `${role === "administrator" ? "Administrator" : "Member"} ${subject}`, + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `${role}-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role, + source: "manual", + suspendedAt: null, + removedAt: null, + }) + const session = await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: + options.freshAuthorizedAt === undefined ? authorizedAt : options.freshAuthorizedAt, + }) + return { membership, session, user } + }) +} + +describe("createAdministrationService() database behavior", () => { + it("rechecks freshness after entering the locked invitation mutation", async () => { + const { store } = await database() + await createAdministrator(store, "39", "administrator-token") + let currentTime = new Date("2026-10-27T18:04:59.999Z") + let lockCount = 0 + const delayedStore: IdentityStore = { + read: store.read, + lock(operation) { + lockCount += 1 + if (lockCount === 2) currentTime = new Date("2026-10-27T18:05:00.000Z") + return store.lock(operation) + }, + } + const service = createAdministrationService({ + store: delayedStore, + now: () => currentTime, + }) + + await expect( + service.invite("administrator-token", { + provider: "github", + issuer: "https://github.com", + subject: "waiting-request", + role: "member", + }), + ).rejects.toMatchObject({ code: "fresh_authorization_required" }) + + expect(await store.read.invitations()).toEqual({ + invitations: [], + nextCursor: null, + }) + }) + + it("creates a seven-day invitation audit and rejects an immediate duplicate", async () => { + const { store } = await database() + const administrator = await createAdministrator(store, "38", "administrator-token") + const now = new Date("2026-10-27T18:01:00.000Z") + const service = createAdministrationService({ store, now: () => now }) + const input = { + provider: "github", + issuer: "https://github.com", + subject: "invited-subject", + role: "member" as const, + } + + const invitation = await service.invite("administrator-token", input) + + expect(invitation).toMatchObject({ + ...input, + createdByUserId: administrator.user.id, + expiresAt: new Date("2026-11-03T18:01:00.000Z"), + consumedAt: null, + revokedAt: null, + }) + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "membership.invitation.create", + outcome: "succeeded", + actorUserId: administrator.user.id, + invitationId: invitation.id, + }), + ]) + await expect(service.invite("administrator-token", input)).rejects.toMatchObject({ + code: "invitation_exists", + }) + expect((await store.read.invitations()).invitations).toHaveLength(1) + }) + + it.each(["active", "suspended"] as const)( + "rejects an invitation for an identity with a %s membership", + async (status) => { + const { store } = await database() + await createAdministrator(store, "37", "administrator-token") + const target = await createAdministrator( + store, + `target-${status}`, + `target-${status}-token`, + { + role: "member", + }, + ) + if (status === "suspended") { + await store.lock((records) => + records.saveMembership({ + userId: target.user.id, + status, + role: "member", + source: "manual", + suspendedAt: new Date("2026-10-27T17:00:00.000Z"), + removedAt: null, + }), + ) + } + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.invite("administrator-token", { + provider: "github", + issuer: "https://github.com", + subject: `target-${status}`, + role: "member", + }), + ).rejects.toMatchObject({ code: "identity_already_assigned" }) + expect((await store.read.invitations()).invitations).toEqual([]) + }, + ) + + it("revokes an expired invitation before creating its replacement", async () => { + const { store } = await database() + const administrator = await createAdministrator(store, "36", "administrator-token") + let now = new Date("2026-10-27T18:01:00.000Z") + const service = createAdministrationService({ store, now: () => now }) + const input = { + provider: "github", + issuer: "https://github.com", + subject: "returning-subject", + role: "member" as const, + } + const expired = await service.invite("administrator-token", input) + now = new Date("2026-11-03T18:01:00.000Z") + await store.lock((records) => records.verifySession(administrator.session.id, now)) + + const replacement = await service.invite("administrator-token", input) + + expect(replacement.id).not.toBe(expired.id) + const invitations = (await store.read.invitations()).invitations + expect(invitations).toHaveLength(2) + expect(invitations.find(({ id }) => id === expired.id)?.revokedAt).toEqual(now) + expect(invitations.find(({ id }) => id === replacement.id)?.revokedAt).toBeNull() + }) + + it("audits a member's denied invitation without creating it", async () => { + const { store } = await database() + await createAdministrator(store, "40", "member-token", { role: "member" }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.invite("member-token", { + provider: "github", + issuer: "https://github.com", + subject: "99", + role: "member", + }), + ).rejects.toMatchObject({ code: "administrator_required" }) + + expect(await store.read.invitations()).toEqual({ + invitations: [], + nextCursor: null, + }) + const [audit] = await store.read.audits() + expect(audit).toMatchObject({ outcome: "denied" }) + expect(audit?.context).toEqual({ reason: "administrator_required" }) + }) + + it("audits stale authorization without applying the requested membership change", async () => { + const { store } = await database() + const administrator = await createAdministrator(store, "41", "stale-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:05:00.000Z"), + }) + + await expect( + service.changeMembership("stale-token", administrator.membership.id, { + role: "member", + }), + ).rejects.toMatchObject({ code: "fresh_authorization_required" }) + + expect(await store.read.membership(administrator.user.id)).toMatchObject({ + status: "active", + role: "administrator", + }) + const [audit] = await store.read.audits() + expect(audit).toMatchObject({ outcome: "denied" }) + expect(audit?.context).toEqual({ reason: "fresh_authorization_required" }) + }) + + it("audits the final-administrator denial without changing membership or sessions", async () => { + const { store } = await database() + const administrator = await createAdministrator(store, "42", "final-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.changeMembership("final-token", administrator.membership.id, { + status: "suspended", + }), + ).rejects.toMatchObject({ code: "last_administrator" }) + + expect(await store.read.membership(administrator.user.id)).toMatchObject({ + status: "active", + role: "administrator", + suspendedAt: null, + }) + expect(await store.read.session(digestSecret("final-token"))).toMatchObject({ + id: administrator.session.id, + }) + const [audit] = await store.read.audits() + expect(audit).toMatchObject({ outcome: "denied" }) + expect(audit?.context).toEqual({ reason: "last_administrator" }) + }) + + it("audits malformed and missing administration targets without partial mutations", async () => { + const { store } = await database() + await createAdministrator(store, "43", "administrator-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await expect( + service.revokeInvitation("administrator-token", "not-a-uuid"), + ).rejects.toMatchObject({ code: "invitation_changed" }) + await expect( + service.changeMembership("administrator-token", "00000000-0000-4000-8000-000000000099", { + role: "member", + }), + ).rejects.toMatchObject({ code: "membership_not_found" }) + + expect(await store.read.invitations()).toEqual({ + invitations: [], + nextCursor: null, + }) + expect(await store.lock((records) => records.activeAdministratorCount())).toBe(1) + const audits = await store.read.audits() + expect(audits.every(({ outcome }) => outcome === "denied")).toBe(true) + expect(audits.map(({ context }) => context)).toEqual( + expect.arrayContaining([ + { reason: "invitation_changed" }, + { reason: "membership_not_found" }, + ]), + ) + expect(audits).toHaveLength(2) + }) + + it("preserves the suspension timestamp when only the suspended member's role changes", async () => { + const { store } = await database() + await createAdministrator(store, "41", "administrator-token") + const suspendedAt = new Date("2026-10-20T12:00:00.000Z") + const target = await store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Suspended member", + avatarUrl: null, + }) + return records.saveMembership({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + suspendedAt, + removedAt: null, + }) + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await service.changeMembership("administrator-token", target.id, { + role: "administrator", + }) + + expect(await store.read.membership(target.userId)).toMatchObject({ + status: "suspended", + role: "administrator", + suspendedAt, + removedAt: null, + }) + }) + + it("audits both changes when restoring a suspended member as an administrator", async () => { + const { store } = await database() + await createAdministrator(store, "44", "administrator-token") + const target = await store.lock(async (records) => { + const user = await records.createUser({ + displayName: "Suspended member", + avatarUrl: null, + }) + return records.saveMembership({ + userId: user.id, + status: "suspended", + role: "member", + source: "manual", + suspendedAt: new Date("2026-10-20T12:00:00.000Z"), + removedAt: null, + }) + }) + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + await service.changeMembership("administrator-token", target.id, { + role: "administrator", + status: "active", + }) + + expect(await store.read.membership(target.userId)).toMatchObject({ + status: "active", + role: "administrator", + suspendedAt: null, + removedAt: null, + }) + const audits = (await store.read.audits()).filter( + ({ event, targetMembershipId }) => + event === "membership.change" && targetMembershipId === target.id, + ) + expect(audits.map(({ context }) => context)).toEqual( + expect.arrayContaining([{ reason: "status_active" }, { reason: "role_administrator" }]), + ) + expect(audits).toHaveLength(2) + }) + + it("removes a membership with its sessions and refuses restoration through PATCH", async () => { + const { store } = await database() + await createAdministrator(store, "45", "administrator-token") + const target = await createAdministrator(store, "46", "member-token", { role: "member" }) + const removedAt = new Date("2026-10-27T18:01:00.000Z") + const service = createAdministrationService({ store, now: () => removedAt }) + + await service.changeMembership("administrator-token", target.membership.id, { + status: "removed", + }) + + expect(await store.read.membership(target.user.id)).toMatchObject({ + status: "removed", + suspendedAt: null, + removedAt, + }) + expect(await store.read.session(digestSecret("member-token"))).toBeNull() + + await expect( + service.changeMembership("administrator-token", target.membership.id, { + status: "active", + }), + ).rejects.toMatchObject({ code: "membership_removed" }) + expect(await store.read.membership(target.user.id)).toMatchObject({ + status: "removed", + removedAt, + }) + }) + + 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.lock((records) => records.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() + const audits = await store.read.audits() + expect( + audits.find( + ({ event, outcome, targetMembershipId }) => + event === "membership.change" && + outcome === "succeeded" && + targetMembershipId === suspended?.id, + ), + ).toMatchObject({ context: { reason: "status_suspended" } }) + expect(audits.every(({ outcome }) => outcome === "succeeded" || outcome === "denied")).toBe( + true, + ) + }) +}) diff --git a/hub/server/domains/administration/create-service/index.test.ts b/hub/server/domains/administration/create-service/index.test.ts new file mode 100644 index 00000000..be319663 --- /dev/null +++ b/hub/server/domains/administration/create-service/index.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest" +import type { Mocked } from "vitest" + +import { createAdministrationService } from "#server/domains/administration/create-service" +import type { IdentityRecords, IdentityStore } from "#server/domains/identity/create-store/types" +import { digestSecret } from "#server/utils/digest-secret" + +const now = new Date("2026-10-27T18:00:00.000Z") +const administratorMembership = { + id: "00000000-0000-4000-8000-000000000001", + userId: "administrator", + status: "active" as const, + role: "administrator" as const, + source: "manual", + suspendedAt: null, + removedAt: null, +} +const administratorSession = { + id: "session", + userId: "administrator", + externalIdentityId: "identity", + authenticationProviderRevisionId: "revision", + tokenDigest: digestSecret("session-token"), + signedInAt: now, + freshAuthorizedAt: now, + user: { id: "administrator", displayName: "Administrator", avatarUrl: null }, + externalIdentity: { + id: "identity", + userId: "administrator", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "administrator", + displayName: "Administrator", + avatarUrl: null, + }, + authenticationProviderRevision: { + id: "revision", + authenticationProviderId: "provider", + revision: 1, + issuer: "https://github.com", + configuration: {}, + authenticationProvider: { + id: "provider", + key: "github", + type: "github", + enabled: true, + }, + }, +} + +function records(overrides: Partial> = {}) { + return Object.assign( + { + session: vi.fn().mockResolvedValue(administratorSession), + sessionById: vi.fn().mockResolvedValue(administratorSession), + membership: vi.fn().mockResolvedValue(administratorMembership), + membershipById: vi.fn().mockResolvedValue(null), + activeAdministratorCount: vi.fn().mockResolvedValue(2), + revokeExpiredInvitations: vi.fn(), + externalIdentity: vi.fn().mockResolvedValue(null), + usableInvitation: vi.fn().mockResolvedValue(null), + createInvitation: vi.fn(), + revokeInvitation: vi.fn().mockResolvedValue(false), + saveMembership: vi.fn(), + deleteUserSessions: vi.fn(), + audit: vi.fn(), + }, + overrides, + ) +} + +function service(identityRecords: ReturnType) { + const typedRecords = identityRecords as unknown as IdentityRecords + const store: IdentityStore = { + read: typedRecords, + lock: (operation) => operation(typedRecords), + } + return createAdministrationService({ store, now: () => now }) +} + +describe("createAdministrationService()", () => { + it("rejects a future fresh authorization timestamp", async () => { + const identityRecords = records({ + session: vi.fn().mockResolvedValue({ + ...administratorSession, + freshAuthorizedAt: new Date(now.getTime() + 1), + }), + }) + + await expect( + service(identityRecords).invite("session-token", { + provider: "github", + issuer: "https://github.com", + subject: "99", + role: "member", + }), + ).rejects.toMatchObject({ + status: 403, + code: "fresh_authorization_required", + message: "Verify your identity again.", + }) + expect(identityRecords.createInvitation).not.toHaveBeenCalled() + }) + + it("rejects a malformed invitation ID with the stable unavailable response", async () => { + const identityRecords = records() + + await expect( + service(identityRecords).revokeInvitation("session-token", "not-a-uuid"), + ).rejects.toMatchObject({ + status: 409, + code: "invitation_changed", + message: "The invitation is no longer available.", + }) + expect(identityRecords.revokeInvitation).not.toHaveBeenCalled() + }) + + it("rejects malformed and missing membership IDs with the stable not-found response", async () => { + const identityRecords = records() + const administration = service(identityRecords) + + await expect( + administration.changeMembership("session-token", "not-a-uuid", { + role: "member", + }), + ).rejects.toMatchObject({ + status: 404, + code: "membership_not_found", + message: "The membership was not found.", + }) + expect(identityRecords.membershipById).not.toHaveBeenCalled() + + await expect( + administration.changeMembership("session-token", "00000000-0000-4000-8000-000000000099", { + role: "member", + }), + ).rejects.toMatchObject({ + status: 404, + code: "membership_not_found", + message: "The membership was not found.", + }) + expect(identityRecords.membershipById).toHaveBeenCalledWith( + "00000000-0000-4000-8000-000000000099", + ) + expect(identityRecords.saveMembership).not.toHaveBeenCalled() + }) +}) 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..809df37f --- /dev/null +++ b/hub/server/domains/administration/create-service/index.ts @@ -0,0 +1,350 @@ +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 type { IdentityRecords } from "#server/domains/identity/create-store/types" +import { AuthenticationError } from "#server/utils/authentication-error" + +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"]), +}) + +const membershipChangeSchema = z + .object({ + role: z.enum(["member", "administrator"]).optional(), + status: z.enum(["active", "suspended", "removed"]).optional(), + }) + .refine(({ role, status }) => role !== undefined || status !== undefined) + +const idSchema = z.uuid() +const cursorSchema = z.object({ + memberships: z.uuid().nullable().optional(), + invitations: z.uuid().nullable().optional(), +}) + +type CurrentSession = Awaited> + +interface DenialAudit { + event: string +} + +async function getAdministrationSession( + dependencies: AdministrationDependencies, + sessionToken: string, +) { + return dependencies.store.lock(async (records) => { + return getCurrentSession( + { + store: dependencies.store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + now: dependencies.now, + }, + sessionToken, + records, + ) + }) +} + +function requireAdministrator( + dependencies: AdministrationDependencies, + current: CurrentSession, + fresh = true, +) { + if (current.membership.role !== "administrator") { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + const freshnessAge = current.freshAuthorizedAt + ? dependencies.now().getTime() - current.freshAuthorizedAt.getTime() + : null + if (fresh && (freshnessAge === null || freshnessAge < 0 || freshnessAge >= 5 * 60_000)) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Verify your identity again.", + ) + } +} + +async function getLockedAdministrator( + dependencies: AdministrationDependencies, + sessionToken: string, + records: IdentityRecords, +) { + const current = await getCurrentSession( + { + store: dependencies.store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + now: dependencies.now, + }, + sessionToken, + records, + ) + requireAdministrator(dependencies, current) + return current +} + +async function runAuditedMutation( + dependencies: AdministrationDependencies, + sessionToken: string, + audit: DenialAudit, + operation: () => Promise, +): Promise { + let current: CurrentSession | null = null + try { + current = await getAdministrationSession(dependencies, sessionToken) + requireAdministrator(dependencies, current) + return await operation() + } catch (error) { + if (current && error instanceof AuthenticationError) { + const deniedBy = current + await dependencies.store.lock((records) => + records.audit({ + event: audit.event, + outcome: "denied", + actorUserId: deniedBy.userId, + sessionId: deniedBy.id, + authenticationProviderRevisionId: deniedBy.authenticationProviderRevisionId, + context: { reason: error.code }, + }), + ) + } + throw error + } +} + +export function createAdministrationService( + options: Omit & { now?: () => Date }, +): AdministrationService { + const dependencies: AdministrationDependencies = { + ...options, + now: options.now ?? (() => new Date()), + } + + return { + async list(sessionToken, cursors = {}) { + const parsedCursors = cursorSchema.safeParse(cursors) + if (!parsedCursors.success) { + throw new AuthenticationError( + 422, + "invalid_administration_cursor", + "The administration cursor is invalid.", + ) + } + const current = await getAdministrationSession(dependencies, sessionToken) + requireAdministrator(dependencies, current, false) + const [memberships, invitations] = await Promise.all([ + dependencies.store.read.memberships(parsedCursors.data.memberships), + dependencies.store.read.invitations(parsedCursors.data.invitations), + ]) + return { memberships, invitations } + }, + + async invite(sessionToken, input) { + return runAuditedMutation( + dependencies, + sessionToken, + { event: "membership.invitation.create" }, + async () => { + const parsed = invitationSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_invitation", "The invitation is invalid.") + } + return dependencies.store.lock(async (records) => { + const actor = await getLockedAdministrator(dependencies, sessionToken, records) + const now = dependencies.now() + await records.revokeExpiredInvitations(now) + const identity = 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, + 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(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 parsedId = idSchema.safeParse(invitationId) + await runAuditedMutation( + dependencies, + sessionToken, + { event: "membership.invitation.revoke" }, + async () => { + if (!parsedId.success) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation is no longer available.", + ) + } + await dependencies.store.lock(async (records) => { + const actor = await getLockedAdministrator(dependencies, sessionToken, records) + if (!(await records.revokeInvitation(parsedId.data, 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: parsedId.data, + }) + }) + }, + ) + }, + + async changeMembership(sessionToken, membershipId, input) { + const parsedId = idSchema.safeParse(membershipId) + return runAuditedMutation( + dependencies, + sessionToken, + { event: "membership.change" }, + async () => { + const parsed = membershipChangeSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_membership_change", + "The membership change is invalid.", + ) + } + if (!parsedId.success) { + throw new AuthenticationError( + 404, + "membership_not_found", + "The membership was not found.", + ) + } + return dependencies.store.lock(async (records) => { + const actor = await getLockedAdministrator(dependencies, sessionToken, records) + const target = await records.membershipById(parsedId.data) + 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.", + ) + } + const now = dependencies.now() + const changed = await records.saveMembership({ + userId: target.userId, + role: nextRole, + status: nextStatus, + source: target.source, + suspendedAt: + nextStatus === "suspended" + ? target.status === "suspended" + ? target.suspendedAt + : now + : null, + removedAt: nextStatus === "removed" ? now : null, + }) + if (nextStatus !== "active") { + await records.deleteUserSessions(target.userId) + } + const reasons = [ + nextRole !== target.role ? `role_${nextRole}` : null, + nextStatus !== target.status ? `status_${nextStatus}` : null, + ].filter((reason): reason is string => reason !== null) + for (const reason of reasons) { + await records.audit({ + event: "membership.change", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + targetMembershipId: target.id, + context: { reason }, + }) + } + 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..4d55b0bc --- /dev/null +++ b/hub/server/domains/administration/create-service/types.ts @@ -0,0 +1,31 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { + InstallationMembershipPage, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" +import type { + MembershipInvitationPage, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" + +export interface AdministrationService { + list( + sessionToken: string, + cursors?: { memberships?: string | null; invitations?: string | null }, + ): Promise<{ + memberships: InstallationMembershipPage + invitations: MembershipInvitationPage + }> + 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/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index b938c2e6..398959a1 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -38,17 +38,23 @@ export async function completeGitHubAuthorization( "The authorization request is invalid or expired.", ) } - let profile: GitHubUserProfile - let admitted: boolean - try { - profile = await github.identify(code, attempt.pkceVerifier) - admitted = attempt.purpose === "verify" ? true : await admissionPolicy.admit(profile) - } catch (error) { + if (attempt.purpose === "link-identity") { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid.", + ) + } + const authorizationAttempt = attempt + const purpose: AuthorizationPurpose = attempt.purpose + let policyAdmitted = false + async function reportProviderFailure(error: unknown): Promise { await store.lock((records) => records.audit({ event: "authentication.provider_failed", + outcome: "failed", actorUserId: null, - sessionId: attempt.originatingSessionId, + sessionId: authorizationAttempt.originatingSessionId, authenticationProviderRevisionId: providerRevisionId, }), ) @@ -59,18 +65,32 @@ export async function completeGitHubAuthorization( "The authentication provider failed.", ) } - if (!admitted) { - await store.lock((records) => - records.audit({ - event: "authentication.admission_denied", - actorUserId: null, - sessionId: null, - authenticationProviderRevisionId: providerRevisionId, - }), - ) - throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + const profile: GitHubUserProfile = await github + .identify(code, authorizationAttempt.pkceVerifier) + .catch(reportProviderFailure) + if (authorizationAttempt.purpose === "sign-in") { + const policyNeeded = await store.lock(async (records) => { + const identity = await records.externalIdentity( + "github", + configuration.issuer, + profile.subject, + ) + const membership = identity ? await records.membership(identity.userId) : null + const invitation = + !membership || membership.status === "removed" + ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) + : null + return !membership && !invitation + }) + if (policyNeeded) { + try { + policyAdmitted = await admissionPolicy.admit(profile) + } catch (error) { + await reportProviderFailure(error) + } + } } - return store.lock(async (records) => { + const result = await store.lock(async (records) => { const [currentProvider, stateRecord] = await Promise.all([ records.currentAuthenticationProviderRevision("github"), records.identityStoreState(), @@ -85,7 +105,7 @@ export async function completeGitHubAuthorization( "The authorization request was superseded.", ) } - if (attempt.purpose === "verify") { + if (purpose === "verify") { const session = attempt.originatingSessionId ? await records.sessionById(attempt.originatingSessionId) : null @@ -100,13 +120,61 @@ export async function completeGitHubAuthorization( 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" } + 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 invitation = + purpose === "sign-in" && membership?.status !== "active" && membership?.status !== "suspended" + ? await records.usableInvitation("github", configuration.issuer, profile.subject, now()) + : null + if ( + purpose === "sign-in" && + membership?.status !== "active" && + !invitation && + !(policyAdmitted && !membership) + ) { + 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, @@ -136,6 +204,93 @@ export async function completeGitHubAuthorization( 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 (policyAdmitted && !membership) { + membership = await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + await records.audit({ + event: "membership.synchronize", + outcome: "succeeded", + actorUserId: identity.userId, + sessionId: null, + authenticationProviderRevisionId: providerRevisionId, + targetMembershipId: membership.id, + context: { source: "legacy_repository" }, + }) + } + } const sessionToken = randomBytes(32).toString("base64url") const session = await records.createSession({ userId: identity.userId, @@ -147,10 +302,15 @@ export async function completeGitHubAuthorization( }) await records.audit({ event: "session.sign_in", + outcome: "succeeded", actorUserId: identity.userId, sessionId: session.id, authenticationProviderRevisionId: providerRevisionId, }) - return { sessionToken, purpose: "sign-in" } + return { sessionToken, purpose, denied: false as const } }) + if ("denied" in result && result.denied) { + throw new AuthenticationError(403, "admission_denied", "This identity is not admitted.") + } + return result } diff --git a/hub/server/domains/authentication/create-admission-policy/index.ts b/hub/server/domains/authentication/create-admission-policy/index.ts index 0e2882e5..d7ced923 100644 --- a/hub/server/domains/authentication/create-admission-policy/index.ts +++ b/hub/server/domains/authentication/create-admission-policy/index.ts @@ -10,7 +10,7 @@ export function createAdmissionPolicy( ): AdmissionPolicy { const selected = environment.HUB_ADMISSION_POLICY ?? "hub-managed" if (selected === "hub-managed") { - return { name: "hub-managed", admit: () => Promise.resolve(true) } + return { name: "hub-managed", admit: () => Promise.resolve(false) } } if (selected !== "legacy-repository") { throw new Error("HUB_ADMISSION_POLICY must be hub-managed or legacy-repository.") diff --git a/hub/server/domains/authentication/create-runtime/index.ts b/hub/server/domains/authentication/create-runtime/index.ts index 7d0d7d8f..71ef0d1d 100644 --- a/hub/server/domains/authentication/create-runtime/index.ts +++ b/hub/server/domains/authentication/create-runtime/index.ts @@ -1,4 +1,5 @@ import { createAdmissionPolicy } from "#server/domains/authentication/create-admission-policy" +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 { ensureGitHubProviderRevision } from "#server/domains/authentication/ensure-provider-revision" @@ -51,6 +52,15 @@ function readGitHubAuthentication( } } +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 configuration = readGitHubAuthentication(environment, origin) @@ -60,6 +70,11 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv const providerRevision = configuration ? await ensureGitHubProviderRevision(store, configuration) : null + 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:", @@ -68,12 +83,15 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv trustedProxyHops, } return { + administration: createAdministrationService({ store }), service: createAuthenticationService({ store, github: configuration ? createGitHubUserAuthClient(configuration) : null, configuration, providerRevisionId: providerRevision?.id ?? null, 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 index 510dfce9..3419bc1d 100644 --- a/hub/server/domains/authentication/create-service/index.db.test.ts +++ b/hub/server/domains/authentication/create-service/index.db.test.ts @@ -2,10 +2,18 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { createAuthenticationService } from "#server/domains/authentication/create-service" import { createIdentityStore } from "#server/domains/identity/create-store" +import type { GitHubUserAuthClient } from "#server/integrations/github/create-user-auth-client/types" import { digestSecret } from "#server/utils/digest-secret" type IdentityStore = ReturnType +const githubConfiguration = { + clientId: "github-client", + clientSecret: "github-client-secret-fixture", + callbackUrl: "https://hub.example/api/auth/github/callback", + issuer: "https://github.com" as const, +} + async function database() { vi.stubGlobal("prisma", undefined) const { prisma } = await import("../../../integrations/prisma") @@ -41,6 +49,8 @@ async function createAdministrator(store: IdentityStore) { status: "active", role: "administrator", source: "manual", + suspendedAt: null, + removedAt: null, }) return { identity, provider, user } }) @@ -64,7 +74,345 @@ async function createSession( ) } +function createGitHubService( + store: IdentityStore, + providerRevisionId: string, + github: GitHubUserAuthClient, + options: { bootstrapSecret?: string | null; recoverySecret?: string | null } = {}, +) { + return createAuthenticationService({ + store, + github, + configuration: githubConfiguration, + providerRevisionId, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + ...options, + }) +} + describe("createAuthenticationService() database behavior", () => { + it.each(["bootstrap", "recovery"] as const)( + "consumes a real %s attempt and completes administrator enrollment", + async (purpose) => { + const { store } = await database() + const provider = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }), + ) + const github: GitHubUserAuthClient = { + authorizationUrl: (state) => `https://github.example/authorize?state=${state}`, + identify: vi.fn().mockResolvedValue({ + subject: `enrolled-${purpose}`, + username: `enrolled-${purpose}`, + displayName: `Enrolled ${purpose}`, + avatarUrl: null, + }), + } + const secret = `${purpose}-secret` + const now = new Date() + const service = createAuthenticationService({ + store, + github, + configuration: githubConfiguration, + providerRevisionId: provider.id, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + bootstrapSecret: purpose === "bootstrap" ? secret : null, + recoverySecret: purpose === "recovery" ? secret : null, + now: () => now, + }) + + const started = await service.start(purpose, undefined, secret) + const state = new URL(started.authorizationUrl).searchParams.get("state") + expect(state).toBeTruthy() + + const completed = await service.complete("one-use-code", state!, started.browserToken) + + expect(completed.purpose).toBe(purpose) + expect(typeof completed.sessionToken).toBe("string") + const identity = await store.read.externalIdentity( + "github", + "https://github.com", + `enrolled-${purpose}`, + ) + expect(identity).not.toBeNull() + expect(await store.read.membership(identity!.userId)).toMatchObject({ + status: "active", + role: "administrator", + source: purpose, + }) + expect(await store.read.session(digestSecret(completed.sessionToken!))).toMatchObject({ + userId: identity!.userId, + }) + }, + ) + + it("keeps bootstrap completion durable across inactive administrators and service recreation", async () => { + const { prisma, store } = await database() + const provider = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }), + ) + const identify = vi + .fn() + .mockResolvedValueOnce({ + subject: "first-bootstrap", + username: "first-bootstrap", + displayName: "First bootstrap", + avatarUrl: null, + }) + .mockResolvedValueOnce({ + subject: "second-bootstrap", + username: "second-bootstrap", + displayName: "Second bootstrap", + avatarUrl: null, + }) + const github: GitHubUserAuthClient = { + authorizationUrl: (state) => `https://github.example/authorize?state=${state}`, + identify, + } + const service = createGitHubService(store, provider.id, github, { + bootstrapSecret: "bootstrap-secret", + }) + const first = await service.start("bootstrap", undefined, "bootstrap-secret") + const second = await service.start("bootstrap", undefined, "bootstrap-secret") + const firstState = new URL(first.authorizationUrl).searchParams.get("state") + const secondState = new URL(second.authorizationUrl).searchParams.get("state") + const completed = await service.complete("first-code", firstState!, first.browserToken) + const firstIdentity = await store.read.externalIdentity( + "github", + "https://github.com", + "first-bootstrap", + ) + expect(firstIdentity).not.toBeNull() + await store.lock(async (records) => { + const membership = await records.membership(firstIdentity!.userId) + await records.saveMembership({ + userId: firstIdentity!.userId, + status: "suspended", + role: "administrator", + source: membership!.source, + suspendedAt: new Date(), + removedAt: null, + }) + await records.deleteUserSessions(firstIdentity!.userId) + }) + + await expect( + service.complete("second-code", secondState!, second.browserToken), + ).rejects.toMatchObject({ code: "admission_denied" }) + await prisma.authorizationAttempt.deleteMany() + + const recreated = createGitHubService(store, provider.id, github, { + bootstrapSecret: "bootstrap-secret", + }) + await expect(recreated.start("bootstrap", undefined, "bootstrap-secret")).rejects.toMatchObject( + { code: "bootstrap_unavailable" }, + ) + expect(await prisma.user.count()).toBe(1) + expect(await prisma.installationMembership.count()).toBe(1) + expect(await prisma.session.count()).toBe(0) + expect(await prisma.authorizationAttempt.count()).toBe(0) + expect(completed.sessionToken).toBeDefined() + }) + + it("activates a usable invitation once, links it, and creates the invited session", async () => { + const { prisma, store } = await database() + const provider = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }), + ) + const now = new Date() + const invitation = await store.lock((records) => + records.createInvitation({ + createdByUserId: null, + provider: "github", + issuer: "https://github.com", + subject: "invited-user", + role: "administrator", + expiresAt: new Date(now.getTime() + 60_000), + }), + ) + const github: GitHubUserAuthClient = { + authorizationUrl: (state) => `https://github.example/authorize?state=${state}`, + identify: vi.fn().mockResolvedValue({ + subject: "invited-user", + username: "invited-user", + displayName: "Invited user", + avatarUrl: null, + }), + } + const service = createGitHubService(store, provider.id, github) + const started = await service.start("sign-in") + const state = new URL(started.authorizationUrl).searchParams.get("state") + + const completed = await service.complete("code", state!, started.browserToken) + + const savedInvitation = await prisma.membershipInvitation.findUniqueOrThrow({ + where: { id: invitation.id }, + }) + expect(savedInvitation).toMatchObject({ + revokedAt: null, + }) + expect(savedInvitation.consumedAt).toBeInstanceOf(Date) + expect(typeof savedInvitation.activatedIdentityId).toBe("string") + expect(typeof savedInvitation.activatedMembershipId).toBe("string") + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { id: savedInvitation.activatedMembershipId! }, + }), + ).toMatchObject({ + status: "active", + role: "administrator", + source: "invitation", + }) + expect(await store.read.session(digestSecret(completed.sessionToken!))).not.toBeNull() + }) + + it("does not reuse a consumed invitation after its membership is removed", async () => { + const { prisma, store } = await database() + const provider = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }), + ) + await store.lock((records) => + records.createInvitation({ + createdByUserId: null, + provider: "github", + issuer: "https://github.com", + subject: "consumed-user", + role: "member", + expiresAt: new Date(Date.now() + 60_000), + }), + ) + const github: GitHubUserAuthClient = { + authorizationUrl: (state) => `https://github.example/authorize?state=${state}`, + identify: vi.fn().mockResolvedValue({ + subject: "consumed-user", + username: "consumed-user", + displayName: null, + avatarUrl: null, + }), + } + const service = createGitHubService(store, provider.id, github) + const first = await service.start("sign-in") + await service.complete( + "first-code", + new URL(first.authorizationUrl).searchParams.get("state")!, + first.browserToken, + ) + const identity = await store.read.externalIdentity( + "github", + "https://github.com", + "consumed-user", + ) + expect(identity).not.toBeNull() + await store.lock(async (records) => { + const membership = await records.membership(identity!.userId) + await records.saveMembership({ + userId: identity!.userId, + status: "removed", + role: membership!.role, + source: membership!.source, + suspendedAt: null, + removedAt: new Date(), + }) + await records.deleteUserSessions(identity!.userId) + }) + const second = await service.start("sign-in") + + await expect( + service.complete( + "second-code", + new URL(second.authorizationUrl).searchParams.get("state")!, + second.browserToken, + ), + ).rejects.toMatchObject({ code: "admission_denied" }) + expect(await prisma.session.count()).toBe(0) + expect( + await prisma.installationMembership.findUniqueOrThrow({ + where: { userId: identity!.userId }, + }), + ).toMatchObject({ status: "removed" }) + }) + + it.each(["revoked", "expired"] as const)( + "does not admit an identity through a %s invitation", + async (boundary) => { + const { prisma, store } = await database() + const provider = await store.lock((records) => + records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + }), + ) + const now = new Date() + const invitation = await store.lock((records) => + records.createInvitation({ + createdByUserId: null, + provider: "github", + issuer: "https://github.com", + subject: `unusable-${boundary}`, + role: "member", + expiresAt: + boundary === "expired" ? new Date(now.getTime() - 1) : new Date(now.getTime() + 60_000), + }), + ) + if (boundary === "revoked") { + await prisma.membershipInvitation.update({ + where: { id: invitation.id }, + data: { revokedAt: now }, + }) + } + const github: GitHubUserAuthClient = { + authorizationUrl: (state) => `https://github.example/authorize?state=${state}`, + identify: vi.fn().mockResolvedValue({ + subject: `unusable-${boundary}`, + username: `unusable-${boundary}`, + displayName: null, + avatarUrl: null, + }), + } + const service = createGitHubService(store, provider.id, github) + const started = await service.start("sign-in") + const state = new URL(started.authorizationUrl).searchParams.get("state") + + await expect(service.complete("code", state!, started.browserToken)).rejects.toMatchObject({ + code: "admission_denied", + }) + expect(await prisma.user.count()).toBe(0) + expect(await prisma.installationMembership.count()).toBe(0) + expect(await prisma.session.count()).toBe(0) + }, + ) + it("rolls back generation, session deletion, and audit together when revocation fails", async () => { const { store } = await database() const graph = await createAdministrator(store) diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index 3d3825b4..28f01d40 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -49,6 +49,20 @@ function records(overrides: Partial = {}) { 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) @@ -109,7 +123,12 @@ function attempt(overrides: Record = {}) { 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) + const options = dependencies(identityRecords, { + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + }) const service = createAuthenticationService(options) const result = await service.start("sign-in") @@ -203,6 +222,7 @@ describe("createAuthenticationService()", () => { freshAuthorizedAt: now, }), }) + const options = dependencies(identityRecords) options.github.identify.mockResolvedValue({ subject: "42", @@ -230,6 +250,466 @@ describe("createAuthenticationService()", () => { ) }) + it("activates only an exact provider identity invitation before issuing a 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 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 options = dependencies(identityRecords, { + admissionPolicy: { + name: "hub-managed", + admit: vi.fn().mockResolvedValue(false), + }, + }) + 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, + ) + }) + + it("admits an active Hub member without consulting legacy repository policy", async () => { + 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()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue({ + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "invitation", + suspendedAt: null, + removedAt: null, + }), + updateUser: 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 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: "Octocat", + avatarUrl: null, + }) + + const completed = await createAuthenticationService(options).complete( + "code", + "state", + "browser-token", + ) + expect(completed.purpose).toBe("sign-in") + expect(typeof completed.sessionToken).toBe("string") + expect(admissionPolicy.admit).not.toHaveBeenCalled() + }) + + it("uses an administrator invitation before legacy repository admission", 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 identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + usableInvitation: vi.fn().mockResolvedValue(invitation), + createUser: vi.fn().mockResolvedValue({ + id: "user", + displayName: "Octocat", + avatarUrl: null, + }), + saveExternalIdentity: vi.fn().mockResolvedValue({ + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }), + 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 admissionPolicy = { + name: "legacy-repository" as const, + admit: vi.fn().mockResolvedValue(true), + } + const options = dependencies(identityRecords, { admissionPolicy }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await createAuthenticationService(options).complete("code", "state", "browser-token") + + expect(admissionPolicy.admit).not.toHaveBeenCalled() + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ role: "administrator", source: "invitation" }), + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledWith( + "invitation", + "identity", + "membership", + now, + ) + }) + + it("uses a new invitation to readmit a removed member before legacy repository admission", async () => { + 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 invitation = { + id: "invitation", + createdByUserId: "administrator", + activatedIdentityId: null, + activatedMembershipId: null, + provider: "github", + issuer: configuration.issuer, + subject: "42", + role: "member" as const, + expiresAt: new Date(now.getTime() + 60_000), + consumedAt: null, + revokedAt: null, + createdAt: now, + } + const removedMembership = { + id: "membership", + userId: "user", + status: "removed" as const, + role: "member" as const, + source: "manual", + suspendedAt: null, + removedAt: new Date(now.getTime() - 60_000), + } + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + externalIdentity: vi.fn().mockResolvedValue(identity), + membership: vi.fn().mockResolvedValue(removedMembership), + usableInvitation: vi.fn().mockResolvedValue(invitation), + updateUser: vi.fn().mockResolvedValue(identity.user), + saveExternalIdentity: vi.fn().mockResolvedValue(identity), + saveMembership: vi.fn().mockResolvedValue({ + ...removedMembership, + status: "active", + source: "invitation", + removedAt: null, + }), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const admissionPolicy = { + name: "legacy-repository" as const, + admit: vi.fn().mockResolvedValue(true), + } + const options = dependencies(identityRecords, { admissionPolicy }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + const completed = await createAuthenticationService(options).complete( + "code", + "state", + "browser-token", + ) + expect(typeof completed.sessionToken).toBe("string") + expect(admissionPolicy.admit).not.toHaveBeenCalled() + expect(identityRecords.saveMembership).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user", + status: "active", + role: "member", + source: "invitation", + removedAt: null, + }), + ) + expect(identityRecords.consumeInvitation).toHaveBeenCalledOnce() + }) + + it("audits a membership granted by legacy repository admission", async () => { + const identityRecords = records({ + consumeAuthorizationAttempt: vi.fn().mockResolvedValue(attempt()), + createUser: vi.fn().mockResolvedValue({ + id: "user", + displayName: "Octocat", + avatarUrl: null, + }), + saveExternalIdentity: vi.fn().mockResolvedValue({ + id: "identity", + userId: "user", + provider: "github", + issuer: configuration.issuer, + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }), + createSession: vi.fn().mockResolvedValue({ + id: "session", + userId: "user", + externalIdentityId: "identity", + authenticationProviderRevisionId: providerRevision.id, + tokenDigest: "stored-digest", + signedInAt: now, + freshAuthorizedAt: now, + }), + }) + const admissionPolicy = { + name: "legacy-repository" as const, + admit: vi.fn().mockResolvedValue(true), + } + const options = dependencies(identityRecords, { admissionPolicy }) + options.github.identify.mockResolvedValue({ + subject: "42", + username: "octocat", + displayName: "Octocat", + avatarUrl: null, + }) + + await createAuthenticationService(options).complete("code", "state", "browser-token") + + expect(identityRecords.saveMembership).toHaveBeenCalledWith({ + userId: "user", + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + expect(identityRecords.audit).toHaveBeenCalledWith({ + event: "membership.synchronize", + outcome: "succeeded", + actorUserId: "user", + sessionId: null, + authenticationProviderRevisionId: providerRevision.id, + targetMembershipId: "membership", + context: { source: "legacy_repository" }, + }) + }) + + 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()), 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..7f0f942e --- /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), + } + const service = createAuthenticationService({ + store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { + name: "hub-managed", + admit: () => Promise.resolve(false), + }, + }) + + await expect(service.current("session-token")).rejects.toMatchObject({ + status: 401, + code: "membership_inactive", + }) + }, + ) +}) diff --git a/hub/server/domains/authentication/create-service/types.ts b/hub/server/domains/authentication/create-service/types.ts index 19700b9c..cfd6b3c6 100644 --- a/hub/server/domains/authentication/create-service/types.ts +++ b/hub/server/domains/authentication/create-service/types.ts @@ -6,7 +6,7 @@ import type { } from "#server/integrations/github/create-user-auth-client/types" import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" -export type AuthorizationPurpose = "sign-in" | "verify" +export type AuthorizationPurpose = "sign-in" | "verify" | "bootstrap" | "recovery" export interface AdmissionPolicy { name: "hub-managed" | "legacy-repository" @@ -22,13 +22,14 @@ export interface AuthenticationSettings { } export interface AuthenticationContext extends SessionContext { - membership: InstallationMembershipRecord | null + membership: InstallationMembershipRecord } export interface AuthenticationService { start( purpose: AuthorizationPurpose, sessionToken?: string, + enrollmentSecret?: string, ): Promise<{ authorizationUrl: string; browserToken: string }> complete( code: string, @@ -46,5 +47,7 @@ export interface AuthenticationDependencies { configuration: GitHubUserAuthConfiguration | null providerRevisionId: string | null admissionPolicy: AdmissionPolicy + bootstrapSecret?: string | null + recoverySecret?: string | null now: () => Date } diff --git a/hub/server/domains/authentication/get-current-session/index.ts b/hub/server/domains/authentication/get-current-session/index.ts index f9f620bf..2ec3974b 100644 --- a/hub/server/domains/authentication/get-current-session/index.ts +++ b/hub/server/domains/authentication/get-current-session/index.ts @@ -13,5 +13,8 @@ export async function getCurrentSession( 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.") + } return { ...session, membership } } diff --git a/hub/server/domains/authentication/revoke-sessions/index.ts b/hub/server/domains/authentication/revoke-sessions/index.ts index 1488055e..fd6e8b2e 100644 --- a/hub/server/domains/authentication/revoke-sessions/index.ts +++ b/hub/server/domains/authentication/revoke-sessions/index.ts @@ -31,6 +31,7 @@ export async function revokeSessions( 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 index dc4cbc4e..0f8160fa 100644 --- a/hub/server/domains/authentication/sign-out/index.ts +++ b/hub/server/domains/authentication/sign-out/index.ts @@ -10,6 +10,7 @@ export async function signOut( 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 index afde2351..92886b82 100644 --- a/hub/server/domains/authentication/start-github-authorization/index.ts +++ b/hub/server/domains/authentication/start-github-authorization/index.ts @@ -7,13 +7,16 @@ import type { 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 { configuration, github, now, providerRevisionId, store } = dependencies + const { bootstrapSecret, configuration, github, now, providerRevisionId, recoverySecret, store } = + dependencies if (!configuration || !github || !providerRevisionId) { throw new AuthenticationError( 503, @@ -24,7 +27,7 @@ export async function startGitHubAuthorization( const state = randomBytes(32).toString("base64url") const browserToken = randomBytes(32).toString("base64url") const pkceVerifier = randomBytes(32).toString("base64url") - await store.lock(async (records) => { + const denial = await store.lock(async (records) => { const provider = await records.currentAuthenticationProviderRevision("github") if (provider?.id !== providerRevisionId) { throw new AuthenticationError( @@ -38,6 +41,50 @@ export async function startGitHubAuthorization( ? 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, @@ -49,7 +96,9 @@ export async function startGitHubAuthorization( 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/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index d843416f..96d00549 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -9,14 +9,27 @@ import { saveExternalIdentity } from "#server/repositories/external-identities/s import { getGitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/get-credentials" import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/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" @@ -32,7 +45,9 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident return { githubIntegrationCredentials: getGitHubIntegrationCredentials.bind(null, database), identityStoreState: getIdentityStoreState.bind(null, database), + bootstrapCompletedAt: getBootstrapCompletedAt.bind(null, database), advanceSessionGeneration: advanceSessionGeneration.bind(null, database), + completeBootstrap: completeBootstrap.bind(null, database), saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), user: getUser.bind(null, database), createUser: createUser.bind(null, database), @@ -40,7 +55,17 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident externalIdentity: getExternalIdentity.bind(null, database), saveExternalIdentity: saveExternalIdentity.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, @@ -58,6 +83,7 @@ export function createIdentityRecords(database: Prisma.TransactionClient): Ident 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), diff --git a/hub/server/domains/identity/create-store/index.db.test.ts b/hub/server/domains/identity/create-store/index.db.test.ts index 50d0812f..5e8502a3 100644 --- a/hub/server/domains/identity/create-store/index.db.test.ts +++ b/hub/server/domains/identity/create-store/index.db.test.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto" import { describe, expect, it, onTestFinished, vi } from "vitest" import { createIdentityStore } from "#server/domains/identity/create-store" @@ -66,13 +67,82 @@ describe("createIdentityStore()", () => { "externalIdentity", "githubIntegrationCredentials", "identityStoreState", + "invitations", "membership", + "memberships", "session", "sessionById", "user", ]) }) + it("pages every active membership and pending invitation without truncation", async () => { + const { prisma, store } = await database() + const users = Array.from({ length: 101 }, (_, index) => ({ + id: randomUUID(), + displayName: `Paged member ${index}`, + avatarUrl: null, + })) + await prisma.user.createMany({ data: users }) + await prisma.installationMembership.createMany({ + data: users.map((user) => ({ + id: randomUUID(), + userId: user.id, + status: "active", + role: "member", + source: "manual", + suspendedAt: null, + removedAt: null, + })), + }) + await prisma.membershipInvitation.createMany({ + data: users.map((_, index) => ({ + id: randomUUID(), + createdByUserId: null, + provider: "github", + issuer: "https://github.com", + subject: `paged-subject-${index}`, + role: "member", + expiresAt: new Date("2026-11-05T18:00:00.000Z"), + })), + }) + + const membershipFirstPage = await store.read.memberships() + const membershipSecondPage = await store.read.memberships(membershipFirstPage.nextCursor) + expect(membershipFirstPage.memberships).toHaveLength(100) + expect(membershipFirstPage.nextCursor).not.toBeNull() + expect(membershipSecondPage).toMatchObject({ + memberships: [expect.objectContaining({ status: "active" })], + nextCursor: null, + }) + expect( + new Set([ + ...membershipFirstPage.memberships.map(({ id }) => id), + ...membershipSecondPage.memberships.map(({ id }) => id), + ]).size, + ).toBe(101) + + const invitationFirstPage = await store.read.invitations() + const invitationSecondPage = await store.read.invitations(invitationFirstPage.nextCursor) + expect(invitationFirstPage.invitations).toHaveLength(100) + expect(invitationFirstPage.nextCursor).not.toBeNull() + expect(invitationSecondPage).toMatchObject({ + invitations: [ + expect.objectContaining({ + consumedAt: null, + revokedAt: null, + }), + ], + nextCursor: null, + }) + expect( + new Set([ + ...invitationFirstPage.invitations.map(({ id }) => id), + ...invitationSecondPage.invitations.map(({ id }) => id), + ]).size, + ).toBe(101) + }) + it("rolls back GitHub integration, session, and audit changes together", async () => { const { store } = await database() const { session } = await createSession(store, "rollback") @@ -226,14 +296,19 @@ describe("createIdentityStore()", () => { status: "active", role: "administrator", source: "bootstrap", + suspendedAt: null, + removedAt: null, }), ) + const suspendedAt = new Date("2026-10-27T18:00:00.000Z") await store.lock((records) => records.saveMembership({ userId: user.id, status: "suspended", role: "member", source: "manual", + suspendedAt, + removedAt: null, }), ) @@ -242,6 +317,8 @@ describe("createIdentityStore()", () => { status: "suspended", role: "member", source: "manual", + suspendedAt, + removedAt: null, }) expect(await store.read.user(user.id)).toEqual(user) expect(user).not.toHaveProperty("administrator") diff --git a/hub/server/domains/identity/create-store/index.ts b/hub/server/domains/identity/create-store/index.ts index e132d0b9..7c897f36 100644 --- a/hub/server/domains/identity/create-store/index.ts +++ b/hub/server/domains/identity/create-store/index.ts @@ -13,6 +13,8 @@ export function createIdentityStore(client: PrismaClient): IdentityStore { externalIdentity: (provider, issuer, subject) => records.externalIdentity(provider, issuer, subject), membership: (userId) => records.membership(userId), + memberships: (cursor) => records.memberships(cursor), + invitations: (cursor) => records.invitations(cursor), session: (tokenDigest) => records.session(tokenDigest), sessionById: (id) => records.sessionById(id), audits: () => records.audits(), diff --git a/hub/server/domains/identity/create-store/types.ts b/hub/server/domains/identity/create-store/types.ts index 8e3cf19d..6b9a64aa 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -6,10 +6,17 @@ import type { } from "#server/repositories/external-identities/types" import type { GitHubIntegrationCredentials } from "#server/repositories/github-integration-credentials/types" import type { + InstallationMembershipDetails, InstallationMembershipInput, + InstallationMembershipPage, InstallationMembershipRecord, } from "#server/repositories/installation-memberships/types" -import type { AuditInput } from "#server/repositories/security-audits/types" +import type { + MembershipInvitationInput, + MembershipInvitationPage, + 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" @@ -37,7 +44,9 @@ export interface SessionContext extends HubSession { export interface IdentityRecords { githubIntegrationCredentials(): Promise identityStoreState(): Promise + bootstrapCompletedAt(): Promise advanceSessionGeneration(): Promise + completeBootstrap(completedAt: Date): Promise saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise user(id: string): Promise createUser(input: UserInput): Promise @@ -51,7 +60,27 @@ export interface IdentityRecords { input: ExternalIdentityInput, ): Promise membership(userId: string): Promise + membershipById(id: string): Promise + memberships(cursor?: string | null): Promise + activeAdministratorCount(): Promise saveMembership(input: InstallationMembershipInput): Promise + createInvitation(input: MembershipInvitationInput): Promise + invitation(id: string): Promise + invitations(cursor?: string | null): 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 @@ -69,6 +98,7 @@ export interface IdentityRecords { invalidateFreshness(): Promise deleteSession(id: string): Promise deleteSessions(): Promise + deleteUserSessions(userId: string): Promise createAuthorizationAttempt( input: Omit & { sessionGeneration?: number @@ -76,7 +106,7 @@ export interface IdentityRecords { ): Promise consumeAuthorizationAttempt(stateDigest: string): Promise audit(input: AuditInput): Promise - audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> + audits(): Promise } export interface IdentityQueries { @@ -89,9 +119,11 @@ export interface IdentityQueries { subject: string, ): Promise<(HubExternalIdentity & { user: HubUser }) | null> membership(userId: string): Promise + memberships(cursor?: string | null): Promise + invitations(cursor?: string | null): Promise session(tokenDigest: string): Promise sessionById(id: string): Promise - audits(): Promise<(AuditInput & { id: string; createdAt: Date })[]> + audits(): Promise } export interface IdentityStore { 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/repositories/authorization-attempts/consume-attempt/index.ts b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts index 638dab02..95123735 100644 --- a/hub/server/repositories/authorization-attempts/consume-attempt/index.ts +++ b/hub/server/repositories/authorization-attempts/consume-attempt/index.ts @@ -31,7 +31,9 @@ export async function consumeAuthorizationAttempt( if ( attempt.purpose !== "sign-in" && attempt.purpose !== "verify" && - attempt.purpose !== "link-identity" + attempt.purpose !== "link-identity" && + attempt.purpose !== "bootstrap" && + attempt.purpose !== "recovery" ) { throw new Error("Stored authorization purpose is invalid.") } diff --git a/hub/server/repositories/authorization-attempts/types.ts b/hub/server/repositories/authorization-attempts/types.ts index 54e18768..9bad2393 100644 --- a/hub/server/repositories/authorization-attempts/types.ts +++ b/hub/server/repositories/authorization-attempts/types.ts @@ -6,7 +6,7 @@ export interface AuthorizationAttempt { browserDigest: string pkceVerifier: string nonce: string | null - purpose: "sign-in" | "verify" | "link-identity" + purpose: "sign-in" | "verify" | "link-identity" | "bootstrap" | "recovery" sessionGeneration: number expiresAt: Date } 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/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/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 index 167de2c0..f79ddec6 100644 --- a/hub/server/repositories/installation-memberships/get-membership/index.ts +++ b/hub/server/repositories/installation-memberships/get-membership/index.ts @@ -1,5 +1,11 @@ import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipRecord } from "../types.ts" -export function getInstallationMembership(database: Prisma.TransactionClient, userId: string) { - return database.installationMembership.findUnique({ where: { userId } }) +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..5c1e734c --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-memberships/index.ts @@ -0,0 +1,21 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipDetails, InstallationMembershipPage } from "../types.ts" + +export async function getInstallationMemberships( + database: Prisma.TransactionClient, + cursor: string | null = null, +): Promise { + const records = (await database.installationMembership.findMany({ + include: { user: { include: { externalIdentities: true } } }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + cursor: cursor ? { id: cursor } : undefined, + skip: cursor ? 1 : undefined, + take: 101, + })) as InstallationMembershipDetails[] + const hasMore = records.length > 100 + const memberships = hasMore ? records.slice(0, 100) : records + return { + memberships, + nextCursor: hasMore ? (memberships.at(-1)?.id ?? null) : null, + } +} diff --git a/hub/server/repositories/installation-memberships/save-membership/index.ts b/hub/server/repositories/installation-memberships/save-membership/index.ts index f38357c9..930b75ae 100644 --- a/hub/server/repositories/installation-memberships/save-membership/index.ts +++ b/hub/server/repositories/installation-memberships/save-membership/index.ts @@ -1,15 +1,18 @@ -import type { InstallationMembershipInput } from "#server/repositories/installation-memberships/types" +import type { + InstallationMembershipInput, + InstallationMembershipRecord, +} from "#server/repositories/installation-memberships/types" import type { Prisma } from "../../../generated/prisma/client.ts" -export function saveInstallationMembership( +export async function saveInstallationMembership( database: Prisma.TransactionClient, input: InstallationMembershipInput, -) { +): Promise { const { userId, ...membership } = input - return database.installationMembership.upsert({ + 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 index 41d75837..4d09271e 100644 --- a/hub/server/repositories/installation-memberships/types.ts +++ b/hub/server/repositories/installation-memberships/types.ts @@ -1,14 +1,29 @@ export interface InstallationMembershipInput { userId: string - status: "active" | "suspended" + status: "active" | "suspended" | "removed" role: "member" | "administrator" - source: "bootstrap" | "manual" | "synchronized" + source: "bootstrap" | "invitation" | "manual" | "recovery" | "synchronized" + suspendedAt?: Date | null + removedAt?: Date | null } export interface InstallationMembershipRecord { id: string userId: string - status: string - role: string - source: 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[] } +} + +export interface InstallationMembershipPage { + memberships: InstallationMembershipDetails[] + nextCursor: string | null +} +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..cac665c6 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitations/index.ts @@ -0,0 +1,20 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationPage, MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitations( + database: Prisma.TransactionClient, + cursor: string | null = null, +): Promise { + const records = (await database.membershipInvitation.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + cursor: cursor ? { id: cursor } : undefined, + skip: cursor ? 1 : undefined, + take: 101, + })) as MembershipInvitationRecord[] + const hasMore = records.length > 100 + const invitations = hasMore ? records.slice(0, 100) : records + return { + invitations, + nextCursor: hasMore ? (invitations.at(-1)?.id ?? null) : null, + } +} 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..af3facb4 --- /dev/null +++ b/hub/server/repositories/membership-invitations/types.ts @@ -0,0 +1,22 @@ +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 +} + +export interface MembershipInvitationPage { + invitations: MembershipInvitationRecord[] + nextCursor: string | null +} diff --git a/hub/server/repositories/security-audits/get-audits/index.ts b/hub/server/repositories/security-audits/get-audits/index.ts index 1b4a2e2c..6783bc47 100644 --- a/hub/server/repositories/security-audits/get-audits/index.ts +++ b/hub/server/repositories/security-audits/get-audits/index.ts @@ -1,8 +1,11 @@ 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 function getSecurityAudits(database: Prisma.TransactionClient) { +export async function getSecurityAudits( + database: Prisma.TransactionClient, +): Promise { return database.securityAudit.findMany({ where: { createdAt: { gte: new Date(Date.now() - auditRetentionMs) } }, orderBy: { createdAt: "desc" }, diff --git a/hub/server/repositories/security-audits/save-audit/index.ts b/hub/server/repositories/security-audits/save-audit/index.ts index df32fdd1..b67c6235 100644 --- a/hub/server/repositories/security-audits/save-audit/index.ts +++ b/hub/server/repositories/security-audits/save-audit/index.ts @@ -4,8 +4,27 @@ 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 }) + await database.securityAudit.create({ + data: { + ...data, + outcome: data.outcome ?? "succeeded", + targetMembershipId: data.targetMembershipId ?? null, + invitationId: data.invitationId ?? null, + context, + }, + }) } diff --git a/hub/server/repositories/security-audits/types.ts b/hub/server/repositories/security-audits/types.ts index ab72e801..7283d69c 100644 --- a/hub/server/repositories/security-audits/types.ts +++ b/hub/server/repositories/security-audits/types.ts @@ -1,6 +1,23 @@ export interface AuditInput { event: string + outcome?: "succeeded" | "denied" | "failed" actorUserId: string | null sessionId: string | null authenticationProviderRevisionId: string | null + targetMembershipId?: string | null + invitationId?: 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 + context: unknown + createdAt: Date } 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/routes/administration.test.ts b/hub/server/routes/administration.test.ts new file mode 100644 index 00000000..dc8db20e --- /dev/null +++ b/hub/server/routes/administration.test.ts @@ -0,0 +1,213 @@ +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 { 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: { memberships: [], nextCursor: null }, + invitations: { invitations: [], nextCursor: null }, + }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + }, + overrides, + ) + return { + service, + administration, + 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("passes independent collection cursors and returns the paged administration response", async () => { + const page = { + memberships: { memberships: [], nextCursor: "next-membership" }, + invitations: { invitations: [], nextCursor: "next-invitation" }, + } + const authentication = runtime({ + list: vi.fn().mockResolvedValue(page), + }) + const baseUrl = await serve(authentication) + + const response = await fetch( + `${baseUrl}/api/administration?membershipsCursor=membership-cursor&invitationsCursor=invitation-cursor`, + { + headers: { cookie: `__Host-replica-session=${sessionToken}` }, + }, + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(page) + expect(authentication.administration.list.mock.calls).toContainEqual([ + sessionToken, + { + memberships: "membership-cursor", + invitations: "invitation-cursor", + }, + ]) + }) + + 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..df022c4c --- /dev/null +++ b/hub/server/routes/api/administration/get/index.ts @@ -0,0 +1,25 @@ +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), { + memberships: + typeof request.query.membershipsCursor === "string" + ? request.query.membershipsCursor + : undefined, + invitations: + typeof request.query.invitationsCursor === "string" + ? request.query.invitationsCursor + : undefined, + }), + ) +} 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/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/session/get/index.ts b/hub/server/routes/api/session/get/index.ts index 3e6cde1b..9f297743 100644 --- a/hub/server/routes/api/session/get/index.ts +++ b/hub/server/routes/api/session/get/index.ts @@ -37,6 +37,11 @@ export async function get( 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 diff --git a/hub/server/routes/authentication.test.ts b/hub/server/routes/authentication.test.ts index d5f4f844..72f1dd04 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -36,6 +36,15 @@ function authentication( }, overrides, ) + const administration = { + list: vi.fn().mockResolvedValue({ + memberships: { memberships: [], nextCursor: null }, + invitations: { invitations: [], nextCursor: null }, + }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + } return { settings: { origin: "https://hub.example", @@ -45,6 +54,7 @@ function authentication( trustedProxyHops, }, service, + administration, } } @@ -105,6 +115,69 @@ describe("authentication routes", () => { expect(runtime.service.start).toHaveBeenCalledWith("sign-in") }) + it.each(["bootstrap", "recovery"] as const)( + "rejects %s enrollment before start when origin or CSRF does not match", + async (purpose) => { + const runtime = authentication() + const baseUrl = await serve(runtime) + const body = JSON.stringify({ secret: `${purpose}-secret` }) + + const wrongOrigin = await fetch(`${baseUrl}/api/auth/${purpose}`, { + method: "POST", + headers: { + ...csrfHeaders, + origin: "https://attacker.example", + "content-type": "application/json", + }, + body, + }) + const wrongCsrf = await fetch(`${baseUrl}/api/auth/${purpose}`, { + method: "POST", + headers: { + ...csrfHeaders, + "content-type": "application/json", + "x-csrf-token": "wrong-token", + }, + body, + }) + + expect(wrongOrigin.status).toBe(403) + expect(wrongCsrf.status).toBe(403) + expect(runtime.service.start).not.toHaveBeenCalled() + }, + ) + + it.each(["bootstrap", "recovery"] as const)( + "binds %s enrollment to its secret and a ten-minute browser cookie", + async (purpose) => { + const runtime = authentication({ + start: vi.fn().mockResolvedValue({ + authorizationUrl: `https://github.com/login/oauth/authorize?state=${purpose}`, + browserToken, + }), + }) + const baseUrl = await serve(runtime) + + const response = await fetch(`${baseUrl}/api/auth/${purpose}`, { + method: "POST", + headers: { ...csrfHeaders, "content-type": "application/json" }, + body: JSON.stringify({ secret: `${purpose}-secret` }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + authorizationUrl: `https://github.com/login/oauth/authorize?state=${purpose}`, + }) + expect(runtime.service.start).toHaveBeenCalledWith(purpose, undefined, `${purpose}-secret`) + expect(response.headers.get("set-cookie")).toMatch( + new RegExp( + `__Host-replica-authorization=${browserToken}; Max-Age=600; Path=/; Expires=[^;]+; HttpOnly; Secure; SameSite=Lax`, + "i", + ), + ) + }, + ) + it("rejects state-changing requests unless both the origin and CSRF token match", async () => { const runtime = authentication() const baseUrl = await serve(runtime) @@ -369,7 +442,15 @@ describe("authentication routes", () => { enabled: true, }, }, - membership: null, + membership: { + id: "membership", + userId: "user", + status: "active", + role: "member", + source: "invitation", + suspendedAt: null, + removedAt: null, + }, }), }) const baseUrl = await serve(runtime) diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index d1af83ee..e33dda0c 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -3,8 +3,14 @@ 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 { 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" @@ -18,7 +24,7 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi if (!authentication) return app.use( - ["/api/auth", "/api/session"], + ["/api/auth", "/api/session", "/api/administration"], prepareAuthenticationRequest.bind(null, authentication.settings.origin), ) const initiationLimit = createRateLimit({ @@ -43,5 +49,29 @@ export function routes(app: Express, authentication?: AuthenticationRoutes): voi 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), + ) + app.use("/api/administration", respondAdministrationError) app.use(respondAuthenticationError) } diff --git a/hub/server/routes/types.ts b/hub/server/routes/types.ts index eadccf7d..8a775590 100644 --- a/hub/server/routes/types.ts +++ b/hub/server/routes/types.ts @@ -2,8 +2,10 @@ import type { AuthenticationService, AuthenticationSettings, } from "#server/domains/authentication/create-service/types" +import type { AdministrationService } from "#server/domains/administration/create-service/types" export interface AuthenticationRoutes { service: AuthenticationService + administration: AdministrationService settings: AuthenticationSettings } diff --git a/hub/test/database/global-setup/index.ts b/hub/test/database/global-setup/index.ts index f6a7ddf6..a0a6ef07 100644 --- a/hub/test/database/global-setup/index.ts +++ b/hub/test/database/global-setup/index.ts @@ -55,8 +55,8 @@ export default async function setup(project: TestProject) { process.execPath, [ join(hubRoot, "node_modules/prisma/build/index.js"), - "db", - "push", + "migrate", + "deploy", "--config", join(hubRoot, "prisma7.config.ts"), ], diff --git a/hub/test/database/migrations/index.db.test.ts b/hub/test/database/migrations/index.db.test.ts new file mode 100644 index 00000000..18dd8ea3 --- /dev/null +++ b/hub/test/database/migrations/index.db.test.ts @@ -0,0 +1,132 @@ +import { randomBytes } from "node:crypto" +import { readFile } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import pg from "pg" +import { describe, expect, it, onTestFinished } from "vitest" + +describe("committed database migrations", () => { + it("upgrades retained identity records and enforces administration lifecycle constraints", async () => { + const adminUrl = process.env.TEST_DATABASE_URL + if (!adminUrl) throw new Error("TEST_DATABASE_URL is required.") + + const databaseName = `replica_migration_${randomBytes(8).toString("hex")}` + const admin = new pg.Client({ connectionString: adminUrl }) + await admin.connect() + await admin.query(`CREATE DATABASE ${pg.escapeIdentifier(databaseName)}`) + const databaseUrl = new URL(adminUrl) + databaseUrl.pathname = databaseName + const database = new pg.Client({ connectionString: databaseUrl.toString() }) + await database.connect() + onTestFinished(async () => { + await database.end() + await admin.query(`DROP DATABASE ${pg.escapeIdentifier(databaseName)} WITH (FORCE)`) + await admin.end() + }) + + const initialMigration = await readFile( + fileURLToPath( + new URL( + "../../../schema/migrations/20261026195151_identity/migration.sql", + import.meta.url, + ), + ), + "utf8", + ) + const administrationMigration = await readFile( + fileURLToPath( + new URL( + "../../../schema/migrations/20261027210000_membership_administration/migration.sql", + import.meta.url, + ), + ), + "utf8", + ) + await database.query(initialMigration) + await database.query(` + INSERT INTO "users" ("id", "display_name") + VALUES ('00000000-0000-4000-8000-000000000101', 'Suspended member'); + INSERT INTO "installation_memberships" ("id", "user_id", "status", "role", "source") + VALUES ( + '00000000-0000-4000-8000-000000000102', + '00000000-0000-4000-8000-000000000101', + 'suspended', + 'member', + 'manual' + ); + INSERT INTO "security_audits" ("id", "event") + VALUES + ('00000000-0000-4000-8000-000000000103', 'authentication.admission_denied'), + ('00000000-0000-4000-8000-000000000104', 'authentication.provider_failed'), + ('00000000-0000-4000-8000-000000000105', 'session.sign_in'); + `) + + await database.query(administrationMigration) + + const membership = await database.query<{ + status: string + suspendedAt: Date | null + removedAt: Date | null + }>(` + SELECT + "status", + "suspended_at" AS "suspendedAt", + "removed_at" AS "removedAt" + FROM "installation_memberships" + WHERE "id" = '00000000-0000-4000-8000-000000000102' + `) + expect(membership.rows[0]).toMatchObject({ + status: "suspended", + removedAt: null, + }) + expect(membership.rows[0]?.suspendedAt).toBeInstanceOf(Date) + const audits = await database.query<{ event: string; outcome: string }>(` + SELECT "event", "outcome" + FROM "security_audits" + ORDER BY "event" + `) + expect(audits.rows).toEqual([ + { event: "authentication.admission_denied", outcome: "denied" }, + { event: "authentication.provider_failed", outcome: "failed" }, + { event: "session.sign_in", outcome: "succeeded" }, + ]) + + await expect( + database.query(` + UPDATE "installation_memberships" + SET "status" = 'active' + WHERE "id" = '00000000-0000-4000-8000-000000000102' + `), + ).rejects.toMatchObject({ code: "23514" }) + await expect( + database.query(` + UPDATE "installation_memberships" + SET "status" = 'removed', "suspended_at" = NULL, "removed_at" = NULL + WHERE "id" = '00000000-0000-4000-8000-000000000102' + `), + ).rejects.toMatchObject({ code: "23514" }) + await expect( + database.query(` + INSERT INTO "membership_invitations" ( + "id", + "provider", + "issuer", + "subject", + "role", + "expires_at", + "consumed_at", + "revoked_at" + ) + VALUES ( + '00000000-0000-4000-8000-000000000106', + 'github', + 'https://github.com', + '42', + 'member', + CURRENT_TIMESTAMP + INTERVAL '1 day', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + `), + ).rejects.toMatchObject({ code: "23514" }) + }) +})