From d8e9c6312b5217d286ef53874865af737d914bae Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 13:45:56 -0700 Subject: [PATCH 1/2] feat(hub): add installation membership administration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- hub/.env.example | 5 + hub/README.md | 4 +- .../application/authentication/index.tsx | 112 +++++++- .../index.test.tsx | 53 ++++ .../membership-administration-page/index.tsx | 204 ++++++++++++++ .../stories.tsx | 72 +++++ .../membership-administration-page/styles.css | 45 +++ .../membership-administration-page/types.ts | 48 ++++ .../pages/sign-in-page/index.test.tsx | 6 + .../javascript/pages/sign-in-page/index.tsx | 52 +++- .../javascript/pages/sign-in-page/stories.tsx | 2 + .../javascript/pages/sign-in-page/styles.css | 24 ++ .../javascript/pages/sign-in-page/types.ts | 2 + hub/docs/README.md | 1 + hub/docs/architecture.md | 8 +- hub/docs/development.md | 2 +- hub/docs/identity.md | 21 +- hub/docs/membership-and-administration.md | 60 ++++ hub/docs/testing-and-operations.md | 2 +- hub/schema/external-identity.prisma | 1 + hub/schema/identity-store-state.prisma | 3 +- hub/schema/installation-membership.prisma | 12 +- hub/schema/membership-invitation.prisma | 31 +++ .../migration.sql | 98 +++++++ hub/schema/security-audit.prisma | 12 +- hub/schema/user.prisma | 1 + hub/server/create-app/index.ts | 2 +- .../create-service/index.db.test.ts | 103 +++++++ .../administration/create-service/index.ts | 261 ++++++++++++++++++ .../administration/create-service/types.ts | 22 ++ .../complete-github-authorization/index.ts | 168 +++++++++-- .../create-admission-policy/index.ts | 2 +- .../authentication/create-runtime/index.ts | 18 ++ .../create-service/index.test.ts | 142 +++++++++- .../create-service/membership.test.ts | 78 ++++++ .../authentication/create-service/types.ts | 7 +- .../get-current-session/index.ts | 3 + .../authentication/revoke-sessions/index.ts | 1 + .../domains/authentication/sign-out/index.ts | 1 + .../start-github-authorization/index.ts | 25 +- .../domains/identity/create-records/index.ts | 26 ++ .../domains/identity/create-store/types.ts | 32 ++- .../respond-administration-error/index.ts | 20 ++ .../authorization-attempts/types.ts | 2 +- .../complete-bootstrap/index.ts | 14 + .../get-bootstrap-completed-at/index.ts | 11 + .../count-active-administrators/index.ts | 7 + .../get-membership-by-id/index.ts | 12 + .../get-membership/index.ts | 10 +- .../get-memberships/index.ts | 12 + .../save-membership/index.ts | 13 +- .../installation-memberships/types.ts | 20 +- .../consume-invitation/index.ts | 15 + .../create-invitation/index.ts | 15 + .../get-invitation/index.ts | 11 + .../get-invitations/index.ts | 11 + .../get-usable-invitation/index.ts | 22 ++ .../revoke-expired-invitations/index.ts | 11 + .../revoke-invitation/index.ts | 13 + .../membership-invitations/types.ts | 17 ++ .../security-audits/get-audits/index.ts | 5 +- .../security-audits/save-audit/index.ts | 21 +- .../repositories/security-audits/types.ts | 17 ++ .../sessions/delete-user-sessions/index.ts | 5 + hub/server/routes/administration.test.ts | 154 +++++++++++ .../routes/api/administration/get/index.ts | 16 ++ .../invitations/delete/index.ts | 23 ++ .../administration/invitations/post/index.ts | 23 ++ .../administration/memberships/patch/index.ts | 24 ++ .../routes/api/auth/enrollment/post/index.ts | 33 +++ hub/server/routes/api/session/get/index.ts | 5 + hub/server/routes/authentication.test.ts | 17 +- hub/server/routes/index.ts | 30 ++ hub/server/routes/types.ts | 2 + 74 files changed, 2320 insertions(+), 63 deletions(-) create mode 100644 hub/app/javascript/pages/membership-administration-page/index.test.tsx create mode 100644 hub/app/javascript/pages/membership-administration-page/index.tsx create mode 100644 hub/app/javascript/pages/membership-administration-page/stories.tsx create mode 100644 hub/app/javascript/pages/membership-administration-page/styles.css create mode 100644 hub/app/javascript/pages/membership-administration-page/types.ts create mode 100644 hub/docs/membership-and-administration.md create mode 100644 hub/schema/membership-invitation.prisma create mode 100644 hub/schema/migrations/20261027210000_membership_administration/migration.sql create mode 100644 hub/server/domains/administration/create-service/index.db.test.ts create mode 100644 hub/server/domains/administration/create-service/index.ts create mode 100644 hub/server/domains/administration/create-service/types.ts create mode 100644 hub/server/domains/authentication/create-service/membership.test.ts create mode 100644 hub/server/integrations/express/respond-administration-error/index.ts create mode 100644 hub/server/repositories/identity-store-state/complete-bootstrap/index.ts create mode 100644 hub/server/repositories/identity-store-state/get-bootstrap-completed-at/index.ts create mode 100644 hub/server/repositories/installation-memberships/count-active-administrators/index.ts create mode 100644 hub/server/repositories/installation-memberships/get-membership-by-id/index.ts create mode 100644 hub/server/repositories/installation-memberships/get-memberships/index.ts create mode 100644 hub/server/repositories/membership-invitations/consume-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/create-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/get-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/get-invitations/index.ts create mode 100644 hub/server/repositories/membership-invitations/get-usable-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts create mode 100644 hub/server/repositories/membership-invitations/revoke-invitation/index.ts create mode 100644 hub/server/repositories/membership-invitations/types.ts create mode 100644 hub/server/repositories/sessions/delete-user-sessions/index.ts create mode 100644 hub/server/routes/administration.test.ts create mode 100644 hub/server/routes/api/administration/get/index.ts create mode 100644 hub/server/routes/api/administration/invitations/delete/index.ts create mode 100644 hub/server/routes/api/administration/invitations/post/index.ts create mode 100644 hub/server/routes/api/administration/memberships/patch/index.ts create mode 100644 hub/server/routes/api/auth/enrollment/post/index.ts diff --git a/hub/.env.example b/hub/.env.example index 3b33a5dc..33368342 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -24,6 +24,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 b6bfa5d8..2ea2df92 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 database has no product models yet. GitHub sign-in works when its dedicated client credentials are configured; invitations, membership administration, product authorization, saved product data, live updates, background jobs, and deployment are not implemented. +Contributors can use PostgreSQL and Prisma locally or in the devcontainer. 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, background 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.tsx b/hub/app/javascript/application/authentication/index.tsx index cbffc8e0..48e9f5e6 100644 --- a/hub/app/javascript/application/authentication/index.tsx +++ b/hub/app/javascript/application/authentication/index.tsx @@ -2,12 +2,27 @@ 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: MembershipRecord[] + invitations: InvitationRecord[] } async function fetchSession(): Promise { @@ -20,10 +35,18 @@ export function AuthenticationApplication() { const [session, setSession] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const [administration, setAdministration] = useState(null) useEffect(() => { fetchSession() - .then(setSession) + .then(async (value) => { + setSession(value) + if (value.membership?.role === "administrator") { + const response = await fetch("/api/administration", { credentials: "same-origin" }) + if (!response.ok) throw new Error("Administration request failed") + setAdministration((await response.json()) as AdministrationResponse) + } + }) .catch(() => setError("Hub could not load your sign-in status.")) .finally(() => setLoading(false)) }, []) @@ -47,6 +70,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) @@ -59,6 +105,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 { @@ -66,6 +113,67 @@ 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 { error?: string } + throw new Error(data.error ?? "Administration request failed") + } + const current = await fetchSession() + setSession(current) + if (current.membership?.role === "administrator") { + const refreshed = await fetch("/api/administration", { credentials: "same-origin" }) + if (!refreshed.ok) throw new Error("Administration request failed") + setAdministration((await refreshed.json()) as AdministrationResponse) + } else { + setAdministration(null) + } + } catch (requestError) { + setError( + requestError instanceof Error ? requestError.message : "Hub could not update membership.", + ) + } finally { + setLoading(false) + } + } + + if (session?.authenticated && session.membership?.role === "administrator" && administration) { + return ( + + void updateAdministration("/api/administration/invitations", "POST", input) + } + onChange={(membershipId, input) => + void updateAdministration( + `/api/administration/memberships/${membershipId}`, + "PATCH", + input, + ) + } + onRevokeInvitation={(invitationId) => + void updateAdministration(`/api/administration/invitations/${invitationId}`, "DELETE") + } + 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..ab291bd7 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.test.tsx @@ -0,0 +1,53 @@ +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("offers restoration for suspension but no lifecycle actions after removal", () => { + const onChange = vi.fn() + render( + + + , + ) + + const suspended = screen.getByText("Suspended member").closest("li")! + fireEvent.click(within(suspended).getByRole("button", { name: "Restore" })) + expect(onChange).toHaveBeenCalledWith("suspended", { status: "active" }) + + const removed = screen.getByText("Removed administrator").closest("li")! + expect(within(removed).queryByRole("button")).not.toBeInTheDocument() + }) +}) diff --git a/hub/app/javascript/pages/membership-administration-page/index.tsx b/hub/app/javascript/pages/membership-administration-page/index.tsx new file mode 100644 index 00000000..478fc56e --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/index.tsx @@ -0,0 +1,204 @@ +import { useState } from "react" + +import { Button, FormControl, Heading, Select, Stack, Text, TextInput } from "@primer/react-brand" + +import type { MembershipAdministrationPageProps } from "./types" +import "./styles.css" + +export function MembershipAdministrationPage({ + memberships, + invitations, + loading, + error, + onInvite, + onChange, + onRevokeInvitation, + onSignOut, +}: MembershipAdministrationPageProps) { + const [provider, setProvider] = useState("github") + const [issuer, setIssuer] = useState("https://github.com") + const [subject, setSubject] = useState("") + const [role, setRole] = useState<"member" | "administrator">("administrator") + + return ( +
+
+
+ + Hub membership + + + Hub admission and installation roles are independent of repository access. + +
+ +
+ + {error ? ( + + {error} + + ) : null} + +
+ + Invite a provider identity + +
{ + event.preventDefault() + onInvite({ provider, issuer, subject, role }) + }} + > + + Provider + setProvider(event.target.value)} + /> + + + Issuer + setIssuer(event.target.value)} + /> + + + Immutable subject + setSubject(event.target.value)} + /> + + + Role + + + +
+
+ +
+ + Memberships + +
    + {memberships.map((membership) => { + const identity = membership.user.externalIdentities[0] + return ( +
  • + + + {membership.user.displayName ?? + identity?.username ?? + identity?.subject ?? + membership.user.id} + + + {identity + ? `${identity.provider} · ${identity.issuer} · ${identity.subject}` + : "No external identity"} + + + {membership.role} · {membership.status} + + {membership.status !== "removed" ? ( +
    + + + +
    + ) : null} +
    +
  • + ) + })} +
+
+ +
+ + Invitations + +
    + {invitations.map((invitation) => ( +
  • + {invitation.subject} + + {invitation.provider} · {invitation.issuer} · {invitation.role} + + {!invitation.consumedAt && !invitation.revokedAt ? ( + + ) : ( + {invitation.consumedAt ? "Activated" : "Revoked"} + )} +
  • + ))} +
+
+
+ ) +} diff --git a/hub/app/javascript/pages/membership-administration-page/stories.tsx b/hub/app/javascript/pages/membership-administration-page/stories.tsx new file mode 100644 index 00000000..1ab480c5 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { MembershipAdministrationPage } from "." + +const meta = { + title: "Hub/Pages/Identity/Membership administration", + component: MembershipAdministrationPage, + parameters: { layout: "fullscreen" }, + args: { + loading: false, + error: null, + memberships: [ + { + id: "membership-1", + role: "administrator", + status: "active", + user: { + id: "user-1", + displayName: "Mona Lisa", + externalIdentities: [ + { + id: "identity-1", + provider: "github", + issuer: "https://github.com", + subject: "42", + username: "mona", + }, + ], + }, + }, + { + id: "membership-2", + role: "member", + status: "suspended", + user: { + id: "user-2", + displayName: "Ada Lovelace", + externalIdentities: [ + { + id: "identity-2", + provider: "oidc", + issuer: "https://login.example", + subject: "ada-1843", + username: "ada", + }, + ], + }, + }, + ], + invitations: [ + { + id: "invitation-1", + provider: "github", + issuer: "https://github.com", + subject: "99", + role: "administrator", + expiresAt: "2026-11-03T20:00:00.000Z", + consumedAt: null, + revokedAt: null, + }, + ], + onInvite: () => undefined, + onChange: () => undefined, + onRevokeInvitation: () => undefined, + onSignOut: () => undefined, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} diff --git a/hub/app/javascript/pages/membership-administration-page/styles.css b/hub/app/javascript/pages/membership-administration-page/styles.css new file mode 100644 index 00000000..8dc46b59 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/styles.css @@ -0,0 +1,45 @@ +.membership-administration-page { + display: grid; + gap: 2rem; + max-inline-size: 72rem; + margin-inline: auto; + padding: 2rem; +} + +.membership-administration-page__header { + display: flex; + gap: 1rem; + align-items: start; + justify-content: space-between; +} + +.membership-administration-page__form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; + align-items: end; + margin-block-start: 1rem; +} + +.membership-administration-page__list { + display: grid; + gap: 0.75rem; + padding: 0; + list-style: none; +} + +.membership-administration-page__list > li { + padding: 1rem; + border: 0.0625rem solid var(--hub-border-default); + border-radius: 0.5rem; +} + +.membership-administration-page__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.membership-administration-page__error { + color: var(--hub-fg-danger); +} diff --git a/hub/app/javascript/pages/membership-administration-page/types.ts b/hub/app/javascript/pages/membership-administration-page/types.ts new file mode 100644 index 00000000..9498ffa5 --- /dev/null +++ b/hub/app/javascript/pages/membership-administration-page/types.ts @@ -0,0 +1,48 @@ +export interface MembershipIdentity { + id: string + provider: string + issuer: string + subject: string + username: string | null +} + +export interface MembershipRecord { + id: string + status: "active" | "suspended" | "removed" + role: "member" | "administrator" + user: { + id: string + displayName: string | null + externalIdentities: MembershipIdentity[] + } +} + +export interface InvitationRecord { + id: string + provider: string + issuer: string + subject: string + role: "member" | "administrator" + expiresAt: string + consumedAt: string | null + revokedAt: string | null +} + +export interface MembershipAdministrationPageProps { + memberships: MembershipRecord[] + invitations: InvitationRecord[] + loading: boolean + error: string | null + onInvite: (input: { + provider: string + issuer: string + subject: string + role: "member" | "administrator" + }) => void + onChange: ( + membershipId: string, + input: { role?: "member" | "administrator"; status?: "active" | "suspended" | "removed" }, + ) => void + onRevokeInvitation: (invitationId: string) => void + 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 2e0baf07..a2fbc192 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 069c4073..55894251 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. - [Testing and operations](testing-and-operations.md): baseline checks and their limits. - [Storybook](storybook.md): retained presentation, fixtures, and screenshot evidence. diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index 52f58790..cd180278 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. Deployment, background jobs, 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. Deployment, background jobs, 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. @@ -35,7 +35,7 @@ Keep the existing `app/javascript/` layout. Application code starts React and wi Imports follow the arrows above. Shared components, hooks, and utilities must not import pages, features, or application code. Keep browser and server code separate. Agree on the API before adding types shared between them. -The frontend displays the 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 43ffbe47..dfe38c65 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 72fd0947..08691490 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 access to at least one installed repository. 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. 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. +- `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. Audit records do not create the future prototype authorization model. -Store operations can share one database transaction and lock a fixed store-state row. Related records and audit events therefore commit or roll back together, and overlapping mutations observe committed state. +Store operations can share one database transaction and lock a fixed store-state row. Related membership, invitation, session, and audit changes therefore commit or roll back together. Overlapping final-administrator changes and invitation activations observe committed state. + +## Installation admission and administration + +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). ## GitHub source-integration credentials @@ -43,6 +52,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 the rewritten initial migration with `npm run db:deploy`. See [Database](database.md) for setup and schema rules. diff --git a/hub/docs/membership-and-administration.md b/hub/docs/membership-and-administration.md new file mode 100644 index 00000000..0408e3c2 --- /dev/null +++ b/hub/docs/membership-and-administration.md @@ -0,0 +1,60 @@ +# 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` + +State-changing requests require the configured origin and matching CSRF cookie/header. Membership and invitation changes also require an active administrator session verified less than five minutes ago. Administration errors return stable codes such as `administrator_required`, `last_administrator`, and `invitation_changed`. + +## Security records + +Membership operations write a structured security record with a bounded action, outcome, actor, session, target membership or invitation, and fixed reason context. Success records commit with their state changes. Records never include host secrets, provider tokens, request payloads, mutable identity labels, or exception text. + +## Non-goals + +This boundary does not 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 724dc1a0..a3887a60 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -53,7 +53,7 @@ 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. +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. Database-enabled jsdom tests use the browser setup and database hooks together. They can arrange records through server code, but frontend code must not import server modules. The setup does not start an API or connect frontend requests to one. diff --git a/hub/schema/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..55025496 --- /dev/null +++ b/hub/schema/migrations/20261027210000_membership_administration/migration.sql @@ -0,0 +1,98 @@ +-- AlterTable +ALTER TABLE "identity_store_states" ADD COLUMN "bootstrap_completed_at" TIMESTAMPTZ(3); + +-- AlterTable +ALTER TABLE "installation_memberships" +ADD COLUMN "suspended_at" TIMESTAMPTZ(3), +ADD COLUMN "removed_at" TIMESTAMPTZ(3); + +-- Existing foundation records are active and must satisfy the lifecycle constraint. +UPDATE "installation_memberships" +SET "suspended_at" = CASE WHEN "status" = 'suspended' THEN CURRENT_TIMESTAMP ELSE NULL END; + +-- Replace the cascading user relation so retained membership and audit linkage cannot disappear. +ALTER TABLE "installation_memberships" DROP CONSTRAINT "installation_memberships_user_id_fkey"; +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_status" CHECK ("status" IN ('active', 'suspended', 'removed')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_role" CHECK ("role" IN ('member', 'administrator')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_source" CHECK ("source" IN ('bootstrap', 'invitation', 'manual', 'recovery', 'synchronized')); + +-- AddCheckConstraint +ALTER TABLE "installation_memberships" ADD CONSTRAINT "installation_memberships_lifecycle" CHECK ( + ("status" = 'active' AND "suspended_at" IS NULL AND "removed_at" IS NULL) OR + ("status" = 'suspended' AND "suspended_at" IS NOT NULL AND "removed_at" IS NULL) OR + ("status" = 'removed' AND "removed_at" IS NOT NULL) +); + +-- CreateTable +CREATE TABLE "membership_invitations" ( + "id" UUID NOT NULL, + "created_by_user_id" UUID, + "activated_identity_id" UUID, + "activated_membership_id" UUID, + "provider" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "role" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "consumed_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "membership_invitations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "membership_invitations_role" CHECK ("role" IN ('member', 'administrator')), + CONSTRAINT "membership_invitations_lifecycle" CHECK (NOT ("consumed_at" IS NOT NULL AND "revoked_at" IS NOT NULL)) +); + +-- CreateIndex +CREATE INDEX "membership_invitations_provider_issuer_subject_idx" ON "membership_invitations"("provider", "issuer", "subject"); + +-- CreateIndex +CREATE INDEX "membership_invitations_expires_at_idx" ON "membership_invitations"("expires_at"); + +-- CreateIndex +CREATE INDEX "membership_invitations_created_by_user_id_idx" ON "membership_invitations"("created_by_user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "membership_invitations_usable_assignment_key" ON "membership_invitations"("provider", "issuer", "subject") WHERE "consumed_at" IS NULL AND "revoked_at" IS NULL; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_activated_identity_id_fkey" FOREIGN KEY ("activated_identity_id") REFERENCES "external_identities"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "membership_invitations" ADD CONSTRAINT "membership_invitations_activated_membership_id_fkey" FOREIGN KEY ("activated_membership_id") REFERENCES "installation_memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "security_audits" +ADD COLUMN "target_membership_id" UUID, +ADD COLUMN "invitation_id" UUID, +ADD COLUMN "outcome" TEXT NOT NULL DEFAULT 'succeeded', +ADD COLUMN "context" JSONB NOT NULL DEFAULT '{}'; + +-- AddCheckConstraint +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_outcome" CHECK ("outcome" IN ('succeeded', 'denied', 'failed')); + +-- AddCheckConstraint +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_context" CHECK (jsonb_typeof("context") = 'object'); + +-- CreateIndex +CREATE INDEX "security_audits_target_membership_id_idx" ON "security_audits"("target_membership_id"); + +-- CreateIndex +CREATE INDEX "security_audits_invitation_id_idx" ON "security_audits"("invitation_id"); + +-- AddForeignKey +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_target_membership_id_fkey" FOREIGN KEY ("target_membership_id") REFERENCES "installation_memberships"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "security_audits" ADD CONSTRAINT "security_audits_invitation_id_fkey" FOREIGN KEY ("invitation_id") REFERENCES "membership_invitations"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/hub/schema/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..2a8e3133 --- /dev/null +++ b/hub/server/domains/administration/create-service/index.db.test.ts @@ -0,0 +1,103 @@ +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) { + const authorizedAt = new Date("2026-10-27T18:00:00.000Z") + return store.lock(async (records) => { + const user = await records.createUser({ + displayName: `Administrator ${subject}`, + avatarUrl: null, + }) + const identity = await records.saveExternalIdentity({ + userId: user.id, + provider: "github", + issuer: "https://github.com", + subject, + username: `administrator-${subject}`, + displayName: user.displayName, + avatarUrl: null, + }) + const provider = + (await records.currentAuthenticationProviderRevision("github")) ?? + (await records.createAuthenticationProviderRevision({ + key: "github", + type: "github", + enabled: true, + revision: 1, + issuer: "https://github.com", + configuration: { clientId: "github-client" }, + })) + const membership = await records.saveMembership({ + userId: user.id, + status: "active", + role: "administrator", + source: "manual", + suspendedAt: null, + removedAt: null, + }) + const session = await records.createSession({ + userId: user.id, + externalIdentityId: identity.id, + authenticationProviderRevisionId: provider.id, + tokenDigest: digestSecret(token), + signedInAt: authorizedAt, + freshAuthorizedAt: authorizedAt, + }) + return { membership, session, user } + }) +} + +describe("createAdministrationService() database behavior", () => { + it("serializes overlapping suspensions so one active administrator and its session remain", async () => { + const { store } = await database() + const first = await createAdministrator(store, "41", "first-token") + const second = await createAdministrator(store, "42", "second-token") + const service = createAdministrationService({ + store, + now: () => new Date("2026-10-27T18:01:00.000Z"), + }) + + const results = await Promise.allSettled([ + service.changeMembership("first-token", second.membership.id, { + status: "suspended", + }), + service.changeMembership("second-token", first.membership.id, { + status: "suspended", + }), + ]) + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1) + expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1) + expect(await store.read.activeAdministratorCount()).toBe(1) + + const memberships = await store.read.memberships() + const active = memberships.find(({ status }) => status === "active") + const suspended = memberships.find(({ status }) => status === "suspended") + expect(active).toBeDefined() + expect(suspended).toBeDefined() + const activeToken = active?.id === first.membership.id ? "first-token" : "second-token" + const suspendedToken = suspended?.id === first.membership.id ? "first-token" : "second-token" + expect(await store.read.session(digestSecret(activeToken))).toBeTruthy() + expect(await store.read.session(digestSecret(suspendedToken))).toBeNull() + expect(await store.read.audits()).toEqual([ + expect.objectContaining({ + event: "membership.change", + outcome: "succeeded", + targetMembershipId: suspended?.id, + context: { reason: "status_suspended" }, + }), + ]) + }) +}) 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..4a96589a --- /dev/null +++ b/hub/server/domains/administration/create-service/index.ts @@ -0,0 +1,261 @@ +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 { 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) + +function requireFreshAdministrator( + dependencies: AdministrationDependencies, + sessionToken: string, + fresh = true, +) { + return dependencies.store.lock(async (records) => { + const current = await getCurrentSession( + { + store: dependencies.store, + github: null, + configuration: null, + providerRevisionId: null, + admissionPolicy: { name: "hub-managed", admit: () => Promise.resolve(false) }, + now: dependencies.now, + }, + sessionToken, + records, + ) + if (current.membership.role !== "administrator") { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + if ( + fresh && + (!current.freshAuthorizedAt || + dependencies.now().getTime() - current.freshAuthorizedAt.getTime() >= 5 * 60_000) + ) { + throw new AuthenticationError( + 403, + "fresh_authorization_required", + "Verify your identity again.", + ) + } + return current + }) +} + +export function createAdministrationService( + options: Omit & { now?: () => Date }, +): AdministrationService { + const dependencies: AdministrationDependencies = { + ...options, + now: options.now ?? (() => new Date()), + } + + return { + async list(sessionToken) { + await requireFreshAdministrator(dependencies, sessionToken, false) + const [memberships, invitations] = await Promise.all([ + dependencies.store.read.memberships(), + dependencies.store.read.invitations(), + ]) + return { memberships, invitations } + }, + + async invite(sessionToken, input) { + const parsed = invitationSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError(422, "invalid_invitation", "The invitation is invalid.") + } + const current = await requireFreshAdministrator(dependencies, sessionToken) + return dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + await records.revokeExpiredInvitations(dependencies.now()) + const identity = await records.externalIdentity( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + ) + const membership = identity ? await records.membership(identity.userId) : null + if (membership && membership.status !== "removed") { + throw new AuthenticationError( + 409, + "identity_already_assigned", + "This identity already has a membership.", + ) + } + const existing = await records.usableInvitation( + parsed.data.provider, + parsed.data.issuer, + parsed.data.subject, + dependencies.now(), + ) + if (existing) { + throw new AuthenticationError(409, "invitation_exists", "An invitation already exists.") + } + const invitation = await records.createInvitation({ + ...parsed.data, + createdByUserId: actor.userId, + expiresAt: new Date(dependencies.now().getTime() + 7 * 24 * 60 * 60_000), + }) + await records.audit({ + event: "membership.invitation.create", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + invitationId: invitation.id, + }) + return invitation + }) + }, + + async revokeInvitation(sessionToken, invitationId) { + const current = await requireFreshAdministrator(dependencies, sessionToken) + await dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + if (!(await records.revokeInvitation(invitationId, dependencies.now()))) { + throw new AuthenticationError( + 409, + "invitation_changed", + "The invitation is no longer available.", + ) + } + await records.audit({ + event: "membership.invitation.revoke", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + invitationId, + }) + }) + }, + + async changeMembership(sessionToken, membershipId, input) { + const parsed = membershipChangeSchema.safeParse(input) + if (!parsed.success) { + throw new AuthenticationError( + 422, + "invalid_membership_change", + "The membership change is invalid.", + ) + } + const current = await requireFreshAdministrator(dependencies, sessionToken) + return dependencies.store.lock(async (records) => { + const actor = await records.sessionById(current.id) + const actorMembership = actor ? await records.membership(actor.userId) : null + if ( + !actor || + actorMembership?.status !== "active" || + actorMembership.role !== "administrator" + ) { + throw new AuthenticationError( + 403, + "administrator_required", + "Administrator access is required.", + ) + } + const target = await records.membershipById(membershipId) + if (!target) { + throw new AuthenticationError( + 404, + "membership_not_found", + "The membership was not found.", + ) + } + if (target.status === "removed") { + throw new AuthenticationError( + 409, + "membership_removed", + "Removed memberships require a new invitation.", + ) + } + const nextRole = parsed.data.role ?? target.role + const nextStatus = parsed.data.status ?? target.status + if (nextRole === target.role && nextStatus === target.status) { + throw new AuthenticationError( + 409, + "membership_unchanged", + "The membership already has this role and status.", + ) + } + const removesAdministrator = + target.status === "active" && + target.role === "administrator" && + (nextStatus !== "active" || nextRole !== "administrator") + if (removesAdministrator && (await records.activeAdministratorCount()) <= 1) { + throw new AuthenticationError( + 409, + "last_administrator", + "The final active administrator cannot be changed.", + ) + } + const changed = await records.saveMembership({ + userId: target.userId, + role: nextRole, + status: nextStatus, + source: target.source, + suspendedAt: nextStatus === "suspended" ? dependencies.now() : null, + removedAt: nextStatus === "removed" ? dependencies.now() : null, + }) + if (nextStatus !== "active") { + await records.deleteUserSessions(target.userId) + } + await records.audit({ + event: "membership.change", + outcome: "succeeded", + actorUserId: actor.userId, + sessionId: actor.id, + authenticationProviderRevisionId: actor.authenticationProviderRevisionId, + targetMembershipId: target.id, + context: { + reason: nextStatus !== target.status ? `status_${nextStatus}` : `role_${nextRole}`, + }, + }) + return changed + }) + }, + } +} diff --git a/hub/server/domains/administration/create-service/types.ts b/hub/server/domains/administration/create-service/types.ts new file mode 100644 index 00000000..967f8b77 --- /dev/null +++ b/hub/server/domains/administration/create-service/types.ts @@ -0,0 +1,22 @@ +import type { IdentityStore } from "#server/domains/identity/create-store/types" +import type { InstallationMembershipRecord } from "#server/repositories/installation-memberships/types" +import type { MembershipInvitationRecord } from "#server/repositories/membership-invitations/types" + +export interface AdministrationService { + list(sessionToken: string): Promise<{ + memberships: unknown[] + invitations: MembershipInvitationRecord[] + }> + invite(sessionToken: string, input: unknown): Promise + revokeInvitation(sessionToken: string, invitationId: string): Promise + changeMembership( + sessionToken: string, + membershipId: string, + input: unknown, + ): Promise +} + +export interface AdministrationDependencies { + store: IdentityStore + now: () => Date +} diff --git a/hub/server/domains/authentication/complete-github-authorization/index.ts b/hub/server/domains/authentication/complete-github-authorization/index.ts index b938c2e6..a6e6a4e5 100644 --- a/hub/server/domains/authentication/complete-github-authorization/index.ts +++ b/hub/server/domains/authentication/complete-github-authorization/index.ts @@ -38,15 +38,26 @@ export async function completeGitHubAuthorization( "The authorization request is invalid or expired.", ) } + if (attempt.purpose === "link-identity") { + throw new AuthenticationError( + 401, + "invalid_authorization", + "The authorization request is invalid.", + ) + } + const purpose: AuthorizationPurpose = attempt.purpose let profile: GitHubUserProfile - let admitted: boolean + let policyAdmitted = false try { profile = await github.identify(code, attempt.pkceVerifier) - admitted = attempt.purpose === "verify" ? true : await admissionPolicy.admit(profile) + if (attempt.purpose === "sign-in") { + policyAdmitted = await admissionPolicy.admit(profile) + } } catch (error) { await store.lock((records) => records.audit({ event: "authentication.provider_failed", + outcome: "failed", actorUserId: null, sessionId: attempt.originatingSessionId, authenticationProviderRevisionId: providerRevisionId, @@ -59,18 +70,7 @@ 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.") - } - 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 +85,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 +100,64 @@ 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" && + !policyAdmitted + ? 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 +187,84 @@ 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) { + await records.saveMembership({ + userId: identity.userId, + status: "active", + role: "member", + source: "synchronized", + suspendedAt: null, + removedAt: null, + }) + } + } const sessionToken = randomBytes(32).toString("base64url") const session = await records.createSession({ userId: identity.userId, @@ -147,10 +276,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 ad4059a7..309e3acb 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 63be7add..cd6a7d1b 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) @@ -59,6 +69,11 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv ? await ensureGitHubProviderRevision(store, configuration) : null const admissionPolicy = createAdmissionPolicy(environment, store) + 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:", @@ -67,12 +82,15 @@ export async function createAuthenticationRuntime(environment: NodeJS.ProcessEnv trustedProxyHops: readTrustedProxyHops(environment), } 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.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index 55555c8b..e49d9247 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -48,6 +48,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) @@ -108,7 +122,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") @@ -202,6 +221,7 @@ describe("createAuthenticationService()", () => { freshAuthorizedAt: now, }), }) + const options = dependencies(identityRecords) options.github.identify.mockResolvedValue({ subject: "42", @@ -229,6 +249,126 @@ 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("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", + }) + 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.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 933104a6..e8a071d6 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..7836278e 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, @@ -38,6 +41,26 @@ export async function startGitHubAuthorization( ? await getCurrentSession(dependencies, sessionToken ?? "", records) : null const stateRecord = await records.identityStoreState() + if (purpose === "bootstrap") { + if ( + (await records.bootstrapCompletedAt()) || + !bootstrapSecret || + !enrollmentSecret || + !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) + ) { + throw new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") + } + } + if (purpose === "recovery") { + if ( + !recoverySecret || + !enrollmentSecret || + !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) || + (await records.activeAdministratorCount()) > 0 + ) { + throw new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") + } + } await records.createAuthorizationAttempt({ authenticationProviderRevisionId: providerRevisionId, originatingSessionId: session?.id ?? null, diff --git a/hub/server/domains/identity/create-records/index.ts b/hub/server/domains/identity/create-records/index.ts index 451ae87d..1c3676b5 100644 --- a/hub/server/domains/identity/create-records/index.ts +++ b/hub/server/domains/identity/create-records/index.ts @@ -9,14 +9,27 @@ import { advanceGitHubIntegrationGeneration } from "#server/repositories/github- import { getGitHubIntegrationState } from "#server/repositories/github-integration-state/get-state" import { saveGitHubIntegrationCredentials } from "#server/repositories/github-integration-state/save-credentials" import { advanceSessionGeneration } from "#server/repositories/identity-store-state/advance-session-generation" +import { completeBootstrap } from "#server/repositories/identity-store-state/complete-bootstrap" +import { getBootstrapCompletedAt } from "#server/repositories/identity-store-state/get-bootstrap-completed-at" import { getIdentityStoreState } from "#server/repositories/identity-store-state/get-state" +import { countActiveAdministrators } from "#server/repositories/installation-memberships/count-active-administrators" import { getInstallationMembership } from "#server/repositories/installation-memberships/get-membership" +import { getInstallationMembershipById } from "#server/repositories/installation-memberships/get-membership-by-id" +import { getInstallationMemberships } from "#server/repositories/installation-memberships/get-memberships" import { saveInstallationMembership } from "#server/repositories/installation-memberships/save-membership" +import { consumeMembershipInvitation } from "#server/repositories/membership-invitations/consume-invitation" +import { createMembershipInvitation } from "#server/repositories/membership-invitations/create-invitation" +import { getMembershipInvitation } from "#server/repositories/membership-invitations/get-invitation" +import { getMembershipInvitations } from "#server/repositories/membership-invitations/get-invitations" +import { getUsableMembershipInvitation } from "#server/repositories/membership-invitations/get-usable-invitation" +import { revokeMembershipInvitation } from "#server/repositories/membership-invitations/revoke-invitation" +import { revokeExpiredMembershipInvitations } from "#server/repositories/membership-invitations/revoke-expired-invitations" import { getSecurityAudits } from "#server/repositories/security-audits/get-audits" import { saveSecurityAudit } from "#server/repositories/security-audits/save-audit" import { createSession } from "#server/repositories/sessions/create-session" import { deleteSession } from "#server/repositories/sessions/delete-session" import { deleteSessions } from "#server/repositories/sessions/delete-sessions" +import { deleteUserSessions } from "#server/repositories/sessions/delete-user-sessions" import { getSession } from "#server/repositories/sessions/get-session" import { getSessionById } from "#server/repositories/sessions/get-session-by-id" import { invalidateFreshness } from "#server/repositories/sessions/invalidate-freshness" @@ -31,7 +44,9 @@ import type { Prisma } from "../../../generated/prisma/client.ts" export function createIdentityRecords(database: Prisma.TransactionClient): IdentityRecords { return { identityStoreState: getIdentityStoreState.bind(null, database), + bootstrapCompletedAt: getBootstrapCompletedAt.bind(null, database), advanceSessionGeneration: advanceSessionGeneration.bind(null, database), + completeBootstrap: completeBootstrap.bind(null, database), githubIntegrationState: getGitHubIntegrationState.bind(null, database), saveGitHubIntegrationCredentials: saveGitHubIntegrationCredentials.bind(null, database), advanceGitHubIntegrationGeneration: advanceGitHubIntegrationGeneration.bind(null, database), @@ -41,7 +56,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, @@ -55,6 +80,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/types.ts b/hub/server/domains/identity/create-store/types.ts index 7ffb9d19..d21f7827 100644 --- a/hub/server/domains/identity/create-store/types.ts +++ b/hub/server/domains/identity/create-store/types.ts @@ -6,10 +6,15 @@ import type { } from "#server/repositories/external-identities/types" import type { GitHubIntegrationState } from "#server/repositories/github-integration-state/types" import type { + InstallationMembershipDetails, InstallationMembershipInput, InstallationMembershipRecord, } from "#server/repositories/installation-memberships/types" -import type { AuditInput } from "#server/repositories/security-audits/types" +import type { + MembershipInvitationInput, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" +import type { AuditInput, SecurityAuditRecord } from "#server/repositories/security-audits/types" import type { HubSession } from "#server/repositories/sessions/types" import type { HubUser, UserInput } from "#server/repositories/users/types" import type { IdentityStoreState } from "#server/repositories/identity-store-state/types" @@ -36,7 +41,9 @@ export interface SessionContext extends HubSession { export interface IdentityRecords { identityStoreState(): Promise + bootstrapCompletedAt(): Promise advanceSessionGeneration(): Promise + completeBootstrap(completedAt: Date): Promise githubIntegrationState(): Promise saveGitHubIntegrationCredentials(encryptedCredentials: string): Promise advanceGitHubIntegrationGeneration(): Promise @@ -52,7 +59,27 @@ export interface IdentityRecords { input: ExternalIdentityInput, ): Promise membership(userId: string): Promise + membershipById(id: string): Promise + memberships(): Promise + activeAdministratorCount(): Promise saveMembership(input: InstallationMembershipInput): Promise + createInvitation(input: MembershipInvitationInput): Promise + invitation(id: string): Promise + invitations(): Promise + usableInvitation( + provider: string, + issuer: string, + subject: string, + now: Date, + ): Promise + consumeInvitation( + id: string, + activatedIdentityId: string, + activatedMembershipId: string, + consumedAt: Date, + ): Promise + revokeInvitation(id: string, revokedAt: Date): Promise + revokeExpiredInvitations(revokedAt: Date): Promise createAuthenticationProviderRevision( input: AuthenticationProviderInput, ): Promise @@ -67,6 +94,7 @@ export interface IdentityRecords { invalidateFreshness(): Promise deleteSession(id: string): Promise deleteSessions(): Promise + deleteUserSessions(userId: string): Promise createAuthorizationAttempt( input: Omit & { sessionGeneration?: number @@ -74,7 +102,7 @@ export interface IdentityRecords { ): Promise consumeAuthorizationAttempt(stateDigest: string): Promise audit(input: AuditInput): 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/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..7d3580ae --- /dev/null +++ b/hub/server/repositories/installation-memberships/get-memberships/index.ts @@ -0,0 +1,12 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { InstallationMembershipDetails } from "../types.ts" + +export async function getInstallationMemberships( + database: Prisma.TransactionClient, +): Promise { + return (await database.installationMembership.findMany({ + include: { user: { include: { externalIdentities: true } } }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 100, + })) as InstallationMembershipDetails[] +} diff --git a/hub/server/repositories/installation-memberships/save-membership/index.ts b/hub/server/repositories/installation-memberships/save-membership/index.ts 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..5823ef23 100644 --- a/hub/server/repositories/installation-memberships/types.ts +++ b/hub/server/repositories/installation-memberships/types.ts @@ -1,14 +1,24 @@ 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[] } +} +import type { HubExternalIdentity } from "#server/repositories/external-identities/types" +import type { HubUser } from "#server/repositories/users/types" diff --git a/hub/server/repositories/membership-invitations/consume-invitation/index.ts b/hub/server/repositories/membership-invitations/consume-invitation/index.ts new file mode 100644 index 00000000..ba1b2194 --- /dev/null +++ b/hub/server/repositories/membership-invitations/consume-invitation/index.ts @@ -0,0 +1,15 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function consumeMembershipInvitation( + database: Prisma.TransactionClient, + id: string, + activatedIdentityId: string, + activatedMembershipId: string, + consumedAt: Date, +): Promise { + const updated = await database.membershipInvitation.updateMany({ + where: { id, consumedAt: null, revokedAt: null, expiresAt: { gt: consumedAt } }, + data: { activatedIdentityId, activatedMembershipId, consumedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/membership-invitations/create-invitation/index.ts b/hub/server/repositories/membership-invitations/create-invitation/index.ts new file mode 100644 index 00000000..dd721b6b --- /dev/null +++ b/hub/server/repositories/membership-invitations/create-invitation/index.ts @@ -0,0 +1,15 @@ +import type { + MembershipInvitationInput, + MembershipInvitationRecord, +} from "#server/repositories/membership-invitations/types" + +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function createMembershipInvitation( + database: Prisma.TransactionClient, + input: MembershipInvitationInput, +): Promise { + return (await database.membershipInvitation.create({ + data: input, + })) as MembershipInvitationRecord +} diff --git a/hub/server/repositories/membership-invitations/get-invitation/index.ts b/hub/server/repositories/membership-invitations/get-invitation/index.ts new file mode 100644 index 00000000..d4d3d130 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitation/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitation( + database: Prisma.TransactionClient, + id: string, +): Promise { + return (await database.membershipInvitation.findUnique({ + where: { id }, + })) as MembershipInvitationRecord | null +} diff --git a/hub/server/repositories/membership-invitations/get-invitations/index.ts b/hub/server/repositories/membership-invitations/get-invitations/index.ts new file mode 100644 index 00000000..e835e410 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-invitations/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getMembershipInvitations( + database: Prisma.TransactionClient, +): Promise { + return (await database.membershipInvitation.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 100, + })) as MembershipInvitationRecord[] +} diff --git a/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts b/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts new file mode 100644 index 00000000..9c7dfed6 --- /dev/null +++ b/hub/server/repositories/membership-invitations/get-usable-invitation/index.ts @@ -0,0 +1,22 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" +import type { MembershipInvitationRecord } from "../types.ts" + +export async function getUsableMembershipInvitation( + database: Prisma.TransactionClient, + provider: string, + issuer: string, + subject: string, + now: Date, +): Promise { + return (await database.membershipInvitation.findFirst({ + where: { + provider, + issuer, + subject, + consumedAt: null, + revokedAt: null, + expiresAt: { gt: now }, + }, + orderBy: { createdAt: "asc" }, + })) as MembershipInvitationRecord | null +} diff --git a/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts b/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts new file mode 100644 index 00000000..ba501bfc --- /dev/null +++ b/hub/server/repositories/membership-invitations/revoke-expired-invitations/index.ts @@ -0,0 +1,11 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function revokeExpiredMembershipInvitations( + database: Prisma.TransactionClient, + revokedAt: Date, +) { + await database.membershipInvitation.updateMany({ + where: { consumedAt: null, revokedAt: null, expiresAt: { lte: revokedAt } }, + data: { revokedAt }, + }) +} diff --git a/hub/server/repositories/membership-invitations/revoke-invitation/index.ts b/hub/server/repositories/membership-invitations/revoke-invitation/index.ts new file mode 100644 index 00000000..563e8665 --- /dev/null +++ b/hub/server/repositories/membership-invitations/revoke-invitation/index.ts @@ -0,0 +1,13 @@ +import type { Prisma } from "../../../generated/prisma/client.ts" + +export async function revokeMembershipInvitation( + database: Prisma.TransactionClient, + id: string, + revokedAt: Date, +): Promise { + const updated = await database.membershipInvitation.updateMany({ + where: { id, consumedAt: null, revokedAt: null }, + data: { revokedAt }, + }) + return updated.count === 1 +} diff --git a/hub/server/repositories/membership-invitations/types.ts b/hub/server/repositories/membership-invitations/types.ts new file mode 100644 index 00000000..86401ab7 --- /dev/null +++ b/hub/server/repositories/membership-invitations/types.ts @@ -0,0 +1,17 @@ +export interface MembershipInvitationInput { + createdByUserId: string | null + provider: string + issuer: string + subject: string + role: "member" | "administrator" + expiresAt: Date +} + +export interface MembershipInvitationRecord extends MembershipInvitationInput { + id: string + activatedIdentityId: string | null + activatedMembershipId: string | null + consumedAt: Date | null + revokedAt: Date | null + createdAt: Date +} diff --git a/hub/server/repositories/security-audits/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..c06ce24d --- /dev/null +++ b/hub/server/routes/administration.test.ts @@ -0,0 +1,154 @@ +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: [], invitations: [] }), + 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]]) + }) + + it("rejects membership changes without matching CSRF before calling the service", async () => { + const authentication = runtime() + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/memberships/membership`, { + method: "PATCH", + headers: { ...protectedHeaders, "x-csrf-token": "wrong-token" }, + body: JSON.stringify({ status: "suspended" }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.administration.changeMembership.mock.calls).toEqual([]) + }) + + it("preserves the last-administrator conflict shape", async () => { + const authentication = runtime({ + changeMembership: vi + .fn() + .mockRejectedValue( + new AuthenticationError( + 409, + "last_administrator", + "The final active administrator cannot be changed.", + ), + ), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/memberships/membership`, { + method: "PATCH", + headers: protectedHeaders, + body: JSON.stringify({ role: "member" }), + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: "last_administrator", + message: "The final active administrator cannot be changed.", + }) + }) + + it("delegates invitation revocation with the authenticated session", async () => { + const authentication = runtime({ + revokeInvitation: vi.fn().mockResolvedValue(undefined), + }) + const baseUrl = await serve(authentication) + + const response = await fetch(`${baseUrl}/api/administration/invitations/invitation`, { + method: "DELETE", + headers: protectedHeaders, + }) + + expect(response.status).toBe(204) + expect(authentication.administration.revokeInvitation.mock.calls).toEqual([ + [sessionToken, "invitation"], + ]) + }) +}) diff --git a/hub/server/routes/api/administration/get/index.ts b/hub/server/routes/api/administration/get/index.ts new file mode 100644 index 00000000..32c85cab --- /dev/null +++ b/hub/server/routes/api/administration/get/index.ts @@ -0,0 +1,16 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" + +export async function get( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + response.json( + await authentication.administration.list(readCookie(request.headers.cookie, cookies.session)), + ) +} diff --git a/hub/server/routes/api/administration/invitations/delete/index.ts b/hub/server/routes/api/administration/invitations/delete/index.ts new file mode 100644 index 00000000..5fc68ebd --- /dev/null +++ b/hub/server/routes/api/administration/invitations/delete/index.ts @@ -0,0 +1,23 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function remove( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + await authentication.administration.revokeInvitation( + readCookie(request.headers.cookie, cookies.session), + typeof request.params.id === "string" ? request.params.id : "", + ) + response.status(204).end() +} diff --git a/hub/server/routes/api/administration/invitations/post/index.ts b/hub/server/routes/api/administration/invitations/post/index.ts new file mode 100644 index 00000000..0a9b576c --- /dev/null +++ b/hub/server/routes/api/administration/invitations/post/index.ts @@ -0,0 +1,23 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function post( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const invitation = await authentication.administration.invite( + readCookie(request.headers.cookie, cookies.session), + request.body, + ) + response.status(201).json({ invitation }) +} diff --git a/hub/server/routes/api/administration/memberships/patch/index.ts b/hub/server/routes/api/administration/memberships/patch/index.ts new file mode 100644 index 00000000..aaa04c8d --- /dev/null +++ b/hub/server/routes/api/administration/memberships/patch/index.ts @@ -0,0 +1,24 @@ +import type { Request, Response } from "express" + +import type { AuthenticationRoutes } from "#server/routes/types" +import { authenticationCookies } from "#server/utils/authentication-cookies" +import { readCookie } from "#server/utils/read-cookie" +import { requireCsrfToken } from "#server/utils/require-csrf-token" + +export async function patch( + authentication: AuthenticationRoutes, + request: Request, + response: Response, +): Promise { + const cookies = authenticationCookies(authentication.settings.secureCookies) + requireCsrfToken( + readCookie(request.headers.cookie, cookies.csrf), + request.get("x-csrf-token") ?? "", + ) + const membership = await authentication.administration.changeMembership( + readCookie(request.headers.cookie, cookies.session), + typeof request.params.id === "string" ? request.params.id : "", + request.body, + ) + response.json({ membership }) +} diff --git a/hub/server/routes/api/auth/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 4308650e..5c6f3b2b 100644 --- a/hub/server/routes/authentication.test.ts +++ b/hub/server/routes/authentication.test.ts @@ -36,6 +36,12 @@ function authentication( }, overrides, ) + const administration = { + list: vi.fn().mockResolvedValue({ memberships: [], invitations: [] }), + invite: vi.fn(), + revokeInvitation: vi.fn(), + changeMembership: vi.fn(), + } return { settings: { origin: "https://hub.example", @@ -45,6 +51,7 @@ function authentication( trustedProxyHops, }, service, + administration, } } @@ -340,7 +347,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..9adb4378 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" @@ -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 } From c3e12cef3d607088c37aaa465449e2643099dbb3 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Tue, 27 Oct 2026 13:52:40 -0700 Subject: [PATCH 2/2] fix(hub): audit enrollment denials Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../create-service/index.test.ts | 54 ++++++++++++++++++ .../start-github-authorization/index.ts | 56 ++++++++++++++----- hub/server/routes/administration.test.ts | 28 ++++++++++ hub/server/routes/index.ts | 2 +- 4 files changed, 124 insertions(+), 16 deletions(-) diff --git a/hub/server/domains/authentication/create-service/index.test.ts b/hub/server/domains/authentication/create-service/index.test.ts index e49d9247..c29b00f6 100644 --- a/hub/server/domains/authentication/create-service/index.test.ts +++ b/hub/server/domains/authentication/create-service/index.test.ts @@ -346,6 +346,14 @@ describe("createAuthenticationService()", () => { 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") @@ -366,6 +374,52 @@ describe("createAuthenticationService()", () => { 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() }) diff --git a/hub/server/domains/authentication/start-github-authorization/index.ts b/hub/server/domains/authentication/start-github-authorization/index.ts index 7836278e..92886b82 100644 --- a/hub/server/domains/authentication/start-github-authorization/index.ts +++ b/hub/server/domains/authentication/start-github-authorization/index.ts @@ -27,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( @@ -42,23 +42,47 @@ export async function startGitHubAuthorization( : null const stateRecord = await records.identityStoreState() if (purpose === "bootstrap") { - if ( - (await records.bootstrapCompletedAt()) || - !bootstrapSecret || - !enrollmentSecret || - !secretsMatch(digestSecret(enrollmentSecret), digestSecret(bootstrapSecret)) - ) { - throw new AuthenticationError(404, "bootstrap_unavailable", "Enrollment is unavailable.") + 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") { - if ( - !recoverySecret || - !enrollmentSecret || - !secretsMatch(digestSecret(enrollmentSecret), digestSecret(recoverySecret)) || - (await records.activeAdministratorCount()) > 0 - ) { - throw new AuthenticationError(404, "recovery_unavailable", "Recovery is unavailable.") + 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({ @@ -72,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/routes/administration.test.ts b/hub/server/routes/administration.test.ts index c06ce24d..534d8e09 100644 --- a/hub/server/routes/administration.test.ts +++ b/hub/server/routes/administration.test.ts @@ -91,6 +91,34 @@ describe("administration routes", () => { message: "Administrator access is required.", }) expect(authentication.administration.list.mock.calls).toEqual([[sessionToken]]) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") + }) + + it.each([ + { name: "missing", origin: undefined }, + { name: "wrong", origin: "https://attacker.example" }, + ])("rejects a $name Origin before an administration mutation runs", async ({ origin }) => { + const authentication = runtime() + const baseUrl = await serve(authentication) + const headers = { ...protectedHeaders } + if (origin) headers.origin = origin + else delete (headers as { origin?: string }).origin + + const response = await fetch(`${baseUrl}/api/administration/invitations`, { + method: "POST", + headers, + body: JSON.stringify({ + provider: "github", + issuer: "https://github.com", + subject: "42", + role: "member", + }), + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: "Request not allowed" }) + expect(authentication.administration.invite.mock.calls).toEqual([]) }) it("rejects membership changes without matching CSRF before calling the service", async () => { diff --git a/hub/server/routes/index.ts b/hub/server/routes/index.ts index 9adb4378..e33dda0c 100644 --- a/hub/server/routes/index.ts +++ b/hub/server/routes/index.ts @@ -24,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({