diff --git a/src/features/instance/applications/components/NewApplication/useCLISteps.test.ts b/src/features/instance/applications/components/NewApplication/useCLISteps.test.ts new file mode 100644 index 000000000..3c320e3a5 --- /dev/null +++ b/src/features/instance/applications/components/NewApplication/useCLISteps.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; +import { buildCLISteps } from './useCLISteps'; + +describe('buildCLISteps', () => { + it('passes the cluster URL to create-harper, and nothing it no longer accepts', () => { + const steps = buildCLISteps('My App', 'https://my-cluster.example.com:9925/'); + const create = steps.find((step) => step.title === 'Create your awesome app'); + expect(create?.code).toBe('npm create harper my-app -- --deploymentURL="https://my-cluster.example.com:9925/"'); + // create-harper dropped --deploymentUsername and the .env password when it moved to `harper login`. + for (const step of steps) { + expect(step.code).not.toMatch(/deploymentUsername/); + expect(step.note ?? '').not.toMatch(/password for this cluster|\.env file/); + } + }); + + it('tells people to take the harper login offer, and that the deploy uses it', () => { + const steps = buildCLISteps('my-app', 'https://my-cluster.example.com:9925/'); + expect(steps.find((step) => step.title === 'Create your awesome app')?.note).toMatch(/harper login/); + expect(steps.find((step) => step.title === 'Deploy!')?.note).toMatch(/login from step 2/); + }); + + it('leaves the URL out when the cluster has none yet', () => { + const create = buildCLISteps('my-app', undefined).find((step) => step.title === 'Create your awesome app'); + expect(create?.code).toBe('npm create harper my-app'); + }); +}); diff --git a/src/features/instance/applications/components/NewApplication/useCLISteps.tsx b/src/features/instance/applications/components/NewApplication/useCLISteps.tsx index 96787b9c1..774cd06e0 100644 --- a/src/features/instance/applications/components/NewApplication/useCLISteps.tsx +++ b/src/features/instance/applications/components/NewApplication/useCLISteps.tsx @@ -1,16 +1,54 @@ import { isLocalStudio } from '@/config/constants'; import { useInstanceClientParams } from '@/config/useInstanceClient'; -import { useInstanceAuth } from '@/hooks/useAuth'; import { Cluster, Instance } from '@/integrations/api/api.patch'; -import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; import { toKebabCase } from '@/lib/string/to-kebab-case'; import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluster'; import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance'; import { useRouteContext } from '@tanstack/react-router'; import { useMemo } from 'react'; +export interface CLIStep { + title: string; + code: string; + note?: string; +} + +/** + * The steps follow create-harper's own flow: it takes the cluster URL, offers to run `harper login`, + * and stores no username or password in the project. + */ +export function buildCLISteps(appName: string, target: string | null | undefined): CLIStep[] { + const directoryName = toKebabCase(appName); + return [ + { + title: 'Install or update the Harper CLI', + code: 'npm install -g harper', + }, + { + title: 'Create your awesome app', + code: `npm create harper ${directoryName}${target ? ` -- --deploymentURL="${target}"` : ''}`, + note: 'You will be asked some easy questions to get you started. When it offers to log in to this cluster,' + + ' say yes: harper login saves a token in your home directory, so no password goes into your project.', + }, + { + title: 'Start local Harper instance', + code: 'npm run dev', + note: 'You will be prompted to configure Harper.', + }, + { + title: 'Learn, make changes, grow!', + code: `cat AGENTS.md`, + }, + { + title: 'Deploy!', + code: `npm run deploy`, + note: "It signs in with the login from step 2. Your project's README explains what a deploy needs in its" + + ' Deployment section.', + }, + ]; +} + export function useCLISteps(appName: string) { - const { user } = useInstanceAuth(); const instanceParams = useInstanceClientParams(); const { instance, cluster }: { instance?: Instance; cluster?: Cluster } = useRouteContext({ strict: false }); const target = isLocalStudio @@ -19,37 +57,5 @@ export function useCLISteps(appName: string) { ? getOperationsUrlForInstance(instance) : getOperationsUrlForCluster(cluster); - return useMemo(() => { - const directoryName = toKebabCase(appName); - const createArgs = [ - user?.username && ` --deploymentUsername="${user?.username}"`, - target && ` --deploymentURL="${target}"`, - ].filter(excludeFalsy).join(''); - return [ - { - title: 'Install or update the Harper CLI', - code: 'npm install -g harper', - }, - { - title: 'Create your awesome app', - code: `npm create harper ${directoryName}${createArgs.length ? ' --' : ''}${createArgs}`, - note: 'You will be asked some easy questions to get you started. To make deployments easy, enter your' - + ' password for this cluster. It will get saved into your local .env file.', - }, - { - title: 'Start local Harper instance', - code: 'npm run dev', - note: 'You will be prompted to configure Harper.', - }, - { - title: 'Learn, make changes, grow!', - code: `cat AGENTS.md`, - }, - { - title: 'Deploy!', - code: `npm run deploy`, - note: 'Remember that .env file? It makes this deployment easy!', - }, - ]; - }, [appName, target, user?.username]); + return useMemo(() => buildCLISteps(appName, target), [appName, target]); } diff --git a/src/features/instance/config/index.tsx b/src/features/instance/config/index.tsx index bee533ac2..8410d02fb 100644 --- a/src/features/instance/config/index.tsx +++ b/src/features/instance/config/index.tsx @@ -3,12 +3,14 @@ import { isLocalStudio } from '@/config/constants'; import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery'; import { useInstanceManagePermission } from '@/hooks/usePermissions'; import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; +import { supportsTrustPolicies } from '@/integrations/api/instance/oidcTrust/oidcTrust'; import { RegistrationInfoResponse } from '@/integrations/api/instance/status/getRegistrationInfo'; import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB'; import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage'; import { useQuery } from '@tanstack/react-query'; import { Link, Outlet, useLoaderData, useParams } from '@tanstack/react-router'; import { + BadgeCheckIcon, GlobeIcon, HandshakeIcon, KeyIcon, @@ -37,6 +39,7 @@ export function ConfigIndex() { // (harper#1554). Floor at the earliest 5.2 prerelease so alpha/beta dev builds pass too — a // plain '5.2.0' gate would exclude every prerelease (SemVer ranks prereleases below the release). const secretsSupported = wasAReleasedBeforeB('5.2.0-alpha.1', version); + const trustPoliciesSupported = supportsTrustPolicies(version); const { clusterId } = params; const canManage = useInstanceManagePermission(); @@ -124,6 +127,18 @@ export function ConfigIndex() { )} + {trustPoliciesSupported && ( +
  • + + Trust Policies + +
  • + )}
  • instanceConfigRoute, + path: 'trust-policies', + head: () => ({ meta: [{ title: 'Trust Policies — Harper Fabric' }] }), + component: ConfigTrustPoliciesIndex, + }); + const instanceConfigCertificatesRoute = createRoute({ getParentRoute: () => instanceConfigRoute, path: 'certificates', @@ -132,6 +140,8 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType ({ + state: { + canManage: true, + version: '5.3.0' as string | undefined, + policies: [] as unknown[] | undefined, + isError: false, + isFetching: false, + versionError: false, + }, + setEnabled: vi.fn(async () => ({ message: 'ok' })), + dropPolicy: vi.fn(async () => ({ message: 'ok' })), + refetch: vi.fn(async () => undefined), + queryOptionsSeen: [] as Array<{ queryKey: readonly unknown[]; enabled?: boolean }>, +})); + +vi.mock('@tanstack/react-query', async (importOriginal) => ({ + ...(await importOriginal()), + useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => { + queryOptionsSeen.push(options); + if (options.queryKey[0] === 'registration_info') { + return { + data: state.version === undefined ? undefined : { version: state.version }, + isError: state.versionError, + }; + } + return { data: state.policies, isFetching: state.isFetching, isError: state.isError, refetch }; + }, +})); +vi.mock('@/config/useInstanceClient', () => ({ + useInstanceClientIdParams: () => ({ instanceClient: {}, entityId: 'entity-1' }), +})); +vi.mock('@/hooks/usePermissions', () => ({ useInstanceManagePermission: () => state.canManage })); +vi.mock('@/integrations/api/instance/oidcTrust/oidcTrust', async (importOriginal) => ({ + ...(await importOriginal()), + useSetTrustPolicyEnabled: () => ({ mutateAsync: setEnabled, isPending: false }), + useDropTrustPolicy: () => ({ mutateAsync: dropPolicy, isPending: false }), +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), loading: vi.fn() } })); + +function policy(overrides: Partial = {}): OidcTrustPolicy { + return { + id: 'github-actions-my-app', + issuer: 'https://token.actions.githubusercontent.com', + audience: 'https://my-cluster.example.com:9925/', + claims: { repository_id: '123456', environment: ['production', 'staging'] }, + user: 'my-app-ci-deploy', + operations: ['deploy_component', 'get_job'], + enabled: true, + description: null, + ...overrides, + }; +} + +function card(id: string) { + return screen.getByRole('listitem', { name: `Trust policy ${id}` }); +} + +beforeEach(() => { + state.canManage = true; + state.version = '5.3.0'; + state.policies = []; + state.isError = false; + state.isFetching = false; + state.versionError = false; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + queryOptionsSeen.length = 0; +}); + +function listQueryEnabled() { + return queryOptionsSeen.filter((options) => options.queryKey[1] === 'list_oidc_trust').at(-1)?.enabled; +} + +describe('ConfigTrustPoliciesIndex', () => { + it('never asks a non-super user instance for its trust policies', () => { + state.canManage = false; + render(); + expect(screen.getByText(/Only super users/)).toBeTruthy(); + expect(listQueryEnabled()).toBe(false); + }); + + it('says which Harper version it needs instead of listing on an older instance', () => { + state.version = '5.2.15'; + render(); + expect(screen.getByText(/need Harper 5.3 or later\. This instance runs 5\.2\.15/)).toBeTruthy(); + expect(listQueryEnabled()).toBe(false); + }); + + it('lists what each policy trusts, who it signs in as, and what its token may run', () => { + state.policies = [policy()]; + render(); + expect(listQueryEnabled()).toBe(true); + const item = within(card('github-actions-my-app')); + expect(item.getByText('Enabled')).toBeTruthy(); + expect(item.getByText('https://my-cluster.example.com:9925/')).toBeTruthy(); + expect(item.getByText('repository_id = 123456')).toBeTruthy(); + expect(item.getByText('environment = production or staging')).toBeTruthy(); + expect(item.getByText('my-app-ci-deploy')).toBeTruthy(); + expect(item.getByText('deploy_component, get_job')).toBeTruthy(); + }); + + it("says an unscoped policy's token carries the user's whole role", () => { + state.policies = [policy({ operations: null })]; + render(); + expect(within(card('github-actions-my-app')).getByText(/the token carries everything my-app-ci-deploy's role/)) + .toBeTruthy(); + }); + + it('shows why Harper refuses a policy, and offers only Drop since re-saving would fail the same check', () => { + state.policies = [policy({ invalid_reason: "names user 'my-app-ci-deploy', which does not exist" })]; + render(); + const item = within(card('github-actions-my-app')); + expect(item.getByText("Can't match")).toBeTruthy(); + expect(item.getByText(/Harper refuses every token for this policy: names user/)).toBeTruthy(); + expect(item.queryByRole('button', { name: 'Disable' })).toBeNull(); + expect(item.getByRole('button', { name: 'Drop' })).toBeTruthy(); + }); + + it('renders a malformed row as text instead of crashing the page', () => { + state.policies = [ + policy({ + id: 'odd', + claims: { repository_id: { nested: true } }, + operations: 'deploy_component', + enabled: 'false', + }), + ]; + render(); + const item = within(card('odd')); + expect(item.getByText('repository_id = {"nested":true}')).toBeTruthy(); + // enabled is not a boolean, so the policy is neither shown as live nor offered a toggle. + expect(item.getByText('Disabled')).toBeTruthy(); + expect(item.queryByRole('button', { name: /able$/ })).toBeNull(); + }); + + it('disables an enabled policy from the row it was loaded as, then refetches', async () => { + const loaded = policy(); + state.policies = [loaded]; + render(); + fireEvent.click(within(card('github-actions-my-app')).getByRole('button', { name: 'Disable' })); + await waitFor(() => expect(refetch).toHaveBeenCalled()); + expect(setEnabled).toHaveBeenCalledWith(expect.objectContaining({ loaded, enabled: false })); + }); + + it('offers Enable on a disabled policy', () => { + state.policies = [policy({ enabled: false })]; + render(); + expect(within(card('github-actions-my-app')).getByRole('button', { name: 'Enable' })).toBeTruthy(); + }); + + it('drops a policy only after confirmation', async () => { + state.policies = [policy()]; + render(); + fireEvent.click(within(card('github-actions-my-app')).getByRole('button', { name: 'Drop' })); + expect(dropPolicy).not.toHaveBeenCalled(); + const dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: /Drop/ })); + // The row as shown, so the drop can refuse one replaced under the same id since. + await waitFor(() => expect(dropPolicy).toHaveBeenCalledWith(expect.objectContaining({ loaded: policy() }))); + await waitFor(() => expect(refetch).toHaveBeenCalled()); + }); + + it('holds the actions while the list is refetching, so a click never acts on a replaced row', () => { + state.policies = [policy()]; + state.isFetching = true; + render(); + const item = within(card('github-actions-my-app')); + expect((item.getByRole('button', { name: 'Disable' }) as HTMLButtonElement).disabled).toBe(true); + expect((item.getByRole('button', { name: 'Drop' }) as HTMLButtonElement).disabled).toBe(true); + }); + + it('says so when the instance version cannot be read, instead of loading forever', () => { + state.version = undefined; + state.versionError = true; + render(); + expect(screen.getByText(/version could not be read/)).toBeTruthy(); + expect(listQueryEnabled()).toBe(false); + }); + + it('says when there are no policies, and when they could not be loaded', () => { + render(); + expect(screen.getByText(/No trust policies yet/)).toBeTruthy(); + cleanup(); + state.isError = true; + render(); + expect(screen.getByText(/could not be loaded/)).toBeTruthy(); + }); +}); diff --git a/src/features/instance/config/trustPolicies/index.tsx b/src/features/instance/config/trustPolicies/index.tsx new file mode 100644 index 000000000..d419fd5f4 --- /dev/null +++ b/src/features/instance/config/trustPolicies/index.tsx @@ -0,0 +1,258 @@ +import { ConfirmDeletionModal } from '@/components/ConfirmDeletionModal'; +import { Loading } from '@/components/Loading'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { useInstanceManagePermission } from '@/hooks/usePermissions'; +import { useRefreshClick } from '@/hooks/useRefreshClick'; +import { + isTrustPolicyScoped, + listOidcTrustQueryOptions, + OidcTrustPolicy, + supportsTrustPolicies, + useDropTrustPolicy, + useSetTrustPolicyEnabled, +} from '@/integrations/api/instance/oidcTrust/oidcTrust'; +import { getRegistrationInfoQueryOptions } from '@/integrations/api/instance/status/getRegistrationInfo'; +import { useQuery } from '@tanstack/react-query'; +import { RefreshCwIcon } from 'lucide-react'; +import { ReactNode, useState } from 'react'; +import { toast } from 'sonner'; + +const DOCS_URL = 'https://docs.harperdb.io/reference/v5/operations-api/operations#add_oidc_trust'; + +/** The cluster's OIDC trust policies: list, disable or enable, and drop. */ +export function ConfigTrustPoliciesIndex() { + const instanceParams = useInstanceClientIdParams(); + const canManage = useInstanceManagePermission(); + const { data: registrationInfo, isError: isVersionError } = useQuery( + getRegistrationInfoQueryOptions(instanceParams), + ); + const supported = registrationInfo === undefined ? undefined : supportsTrustPolicies(registrationInfo.version); + const { data: policies, isFetching, isError, refetch } = useQuery( + listOidcTrustQueryOptions({ ...instanceParams, enabled: canManage && supported === true }), + ); + const onRefreshClick = useRefreshClick(refetch); + + const { mutateAsync: setEnabled, isPending: isSetEnabledPending } = useSetTrustPolicyEnabled(); + const { mutateAsync: dropPolicy, isPending: isDropPending } = useDropTrustPolicy(); + const [dropTarget, setDropTarget] = useState(); + // Held through the refetch too, so a second click never acts on the row the write just replaced. + const busy = isSetEnabledPending || isDropPending || isFetching; + + if (!canManage) { + return Only super users can view or change trust policies.; + } + if (supported === undefined) { + return isVersionError + ? This instance's Harper version could not be read, so its trust policies are not shown. + : ; + } + if (!supported) { + return ( + + Trust policies need Harper 5.3 or later. This instance runs {registrationInfo?.version}. + + ); + } + + // Refetched on failure too: the usual failure is that the row changed or vanished, and the error + // toast asks the reader to review what is there now. + const onToggle = async (policy: OidcTrustPolicy, enabled: boolean) => { + try { + await setEnabled({ ...instanceParams, loaded: policy, enabled }); + toast.success(`${enabled ? 'Enabled' : 'Disabled'} trust policy '${policy.id}'`); + } finally { + await refetch(); + } + }; + + const onDropConfirmed = async () => { + if (!dropTarget) { + return; + } + try { + await dropPolicy({ ...instanceParams, loaded: dropTarget }); + toast.success(`Dropped trust policy '${dropTarget.id}'`); + } finally { + setDropTarget(undefined); + await refetch(); + } + }; + + return ( +
    +
    +
    +

    Trust Policies

    +

    + A trust policy lets a CI workflow, such as GitHub Actions, sign in as a Harper user with its OIDC identity + token instead of a stored password or token. Policies replicate to every node in the cluster. +

    + + Trust policy docs + +
    + +
    + + {isError + ? The trust policies could not be loaded. + : policies === undefined + ? + : policies.length === 0 + ? ( + + No trust policies yet. Create one with the add_oidc_trust operation. + + ) + : ( + // The `.catch`es below only stop an unhandled rejection; the mutation cache already toasts failures. +
      + {policies.map((policy) => ( + void onToggle(policy, enabled).catch(() => undefined)} + onDrop={() => setDropTarget(policy)} + /> + ))} +
    + )} + + !open && setDropTarget(undefined)} + deletionConfirmed={() => void onDropConfirmed().catch(() => undefined)} + deletionPending={isDropPending} + hideDataLossWarning + transitiveVerb="Drop" + presentParticiple="Dropping" + /> +
    + ); +} + +function PageNotice({ children }: { children: ReactNode }) { + return

    {children}

    ; +} + +/** Server data rendered as text, whatever shape it arrived in. */ +function textOf(value: unknown, separator = ', '): string { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { + return value.join(separator); + } + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +function claimEntries(claims: unknown): Array<[string, string]> { + if (!claims || typeof claims !== 'object' || Array.isArray(claims)) { + return [['claims', textOf(claims)]]; + } + // A claim constrained to several values matches a token carrying any one of them. + return Object.entries(claims).map(([name, value]) => [name, textOf(value, ' or ')]); +} + +function TrustPolicyCard({ + policy, + disabled, + onToggle, + onDrop, +}: { + policy: OidcTrustPolicy; + disabled: boolean; + onToggle: (enabled: boolean) => void; + onDrop: () => void; +}) { + const invalidReason = policy.invalid_reason ? textOf(policy.invalid_reason) : undefined; + const enabled = policy.enabled === true; + // Re-saving runs every write-time check again, so a policy Harper already flags would fail it too. + const canToggle = !invalidReason && typeof policy.enabled === 'boolean'; + const user = textOf(policy.user); + return ( +
  • +
    +
    + {policy.id} + {invalidReason + ? Can't match + : enabled + ? Enabled + : Disabled} +
    +
    + {canToggle && ( + + )} + +
    +
    + {invalidReason && ( +

    + Harper refuses every token for this policy: {invalidReason}. +

    + )} + {typeof policy.description === 'string' && policy.description &&

    {policy.description}

    } +
    +
    Issuer
    +
    {textOf(policy.issuer)}
    +
    Audience
    +
    {textOf(policy.audience)}
    +
    Claims
    +
    +
      + {claimEntries(policy.claims).map(([name, value]) => ( +
    • + {name} = {value} +
    • + ))} +
    +
    +
    Signs in as
    +
    {user}
    +
    Operations
    +
    + {isTrustPolicyScoped(policy) + ? {textOf(policy.operations)} + : `Not limited: the token carries everything ${user}'s role allows.`} +
    +
    +
  • + ); +} diff --git a/src/integrations/api/instance/oidcTrust/oidcTrust.test.ts b/src/integrations/api/instance/oidcTrust/oidcTrust.test.ts new file mode 100644 index 000000000..24dc79aa9 --- /dev/null +++ b/src/integrations/api/instance/oidcTrust/oidcTrust.test.ts @@ -0,0 +1,144 @@ +import type { EntityIds } from '@/features/auth/store/authStore'; +import type { AxiosInstance } from 'axios'; +import { describe, expect, it, vi } from 'vitest'; +import { + dropTrustPolicy, + OidcTrustPolicy, + setTrustPolicyEnabled, + supportsTrustPolicies, + trustPolicyResaveRequest, +} from './oidcTrust'; + +function policy(overrides: Partial = {}): OidcTrustPolicy { + return { + id: 'github-actions-my-app', + issuer: 'https://token.actions.githubusercontent.com', + audience: 'https://my-cluster.example.com:9925/', + claims: { + repository_id: '123456', + workflow_ref: 'acme/my-app/.github/workflows/deploy.yaml@refs/heads/main', + environment: 'production', + }, + user: 'my-app-ci-deploy', + operations: ['deploy_component', 'get_job'], + enabled: true, + description: 'Deploys acme/my-app', + updated_by: 'admin', + __createdtime__: 1, + __updatedtime__: 2, + ...overrides, + }; +} + +function clientListing(policies: OidcTrustPolicy[]) { + const post = vi.fn(async (_url: string, body: { operation: string }) => + body.operation === 'list_oidc_trust' ? { data: { policies } } : { data: { message: 'ok' } } + ); + return { + post, + params: { instanceClient: { post } as unknown as AxiosInstance, entityId: 'entity-1' as EntityIds }, + }; +} + +describe('supportsTrustPolicies', () => { + it('starts at the first 5.3 prerelease, which shipped the trust-policy operations', () => { + expect(supportsTrustPolicies(undefined)).toBe(false); + expect(supportsTrustPolicies('5.2.15')).toBe(false); + expect(supportsTrustPolicies('5.3.0-alpha.1')).toBe(true); + expect(supportsTrustPolicies('5.3.2')).toBe(true); + }); +}); + +describe('trustPolicyResaveRequest', () => { + it('sends every stored field back, with only enabled changed', () => { + expect(trustPolicyResaveRequest(policy(), false)).toEqual({ + operation: 'add_oidc_trust', + id: 'github-actions-my-app', + issuer: 'https://token.actions.githubusercontent.com', + audience: 'https://my-cluster.example.com:9925/', + claims: policy().claims, + user: 'my-app-ci-deploy', + operations: ['deploy_component', 'get_job'], + enabled: false, + description: 'Deploys acme/my-app', + }); + }); + + it('omits the nulls add_oidc_trust would reject, keeping an unscoped policy unscoped', () => { + const request = trustPolicyResaveRequest(policy({ operations: null, description: null }), true); + expect(request).not.toHaveProperty('operations'); + expect(request).not.toHaveProperty('description'); + expect(request.enabled).toBe(true); + }); + + it('carries a field a newer Harper stores, which a fixed field list would erase on re-save', () => { + const withNewField = { ...policy(), max_token_lifetime: 900 } as OidcTrustPolicy; + expect(trustPolicyResaveRequest(withNewField, false)).toMatchObject({ max_token_lifetime: 900, enabled: false }); + }); + + it('refuses a malformed scope rather than dropping it, which would widen the token', () => { + expect(() => trustPolicyResaveRequest(policy({ operations: 'deploy_component' }), false)).toThrow( + /malformed operations scope/, + ); + expect(() => trustPolicyResaveRequest(policy({ operations: ['deploy_component', 7] }), false)).toThrow( + /malformed operations scope/, + ); + }); +}); + +describe('setTrustPolicyEnabled', () => { + it('re-reads the policy and writes the fresh row with enabled flipped', async () => { + const { post, params } = clientListing([policy()]); + await setTrustPolicyEnabled({ ...params, loaded: policy(), enabled: false }); + expect(post).toHaveBeenCalledTimes(2); + expect(post.mock.calls[0][1]).toEqual({ operation: 'list_oidc_trust' }); + expect(post.mock.calls[1][1]).toEqual(trustPolicyResaveRequest(policy(), false)); + }); + + it('refuses when another administrator changed the policy after the page loaded it', async () => { + // Re-saving the stale copy would quietly undo the narrowing. + const narrowed = policy({ operations: ['deploy_component'], __updatedtime__: 3 }); + const { post, params } = clientListing([narrowed]); + await expect(setTrustPolicyEnabled({ ...params, loaded: policy(), enabled: false })).rejects.toThrow( + /changed since this page loaded it/, + ); + expect(post).toHaveBeenCalledTimes(1); + }); + + it('refuses a change to a resent field even when the update time is unchanged', async () => { + const { post, params } = clientListing([policy({ claims: { repository_id: '999' } })]); + await expect(setTrustPolicyEnabled({ ...params, loaded: policy(), enabled: false })).rejects.toThrow( + /changed since this page loaded it/, + ); + expect(post).toHaveBeenCalledTimes(1); + }); + + it('accepts the same row with its claims listed in another order', async () => { + const reordered = policy({ claims: Object.fromEntries(Object.entries(policy().claims).reverse()) }); + const { post, params } = clientListing([reordered]); + await setTrustPolicyEnabled({ ...params, loaded: policy(), enabled: false }); + expect(post).toHaveBeenCalledTimes(2); + }); + + it('refuses when the policy has been dropped, rather than recreating it', async () => { + const { post, params } = clientListing([]); + await expect(setTrustPolicyEnabled({ ...params, loaded: policy(), enabled: true })).rejects.toThrow( + /no longer exists/, + ); + expect(post).toHaveBeenCalledTimes(1); + }); +}); + +describe('dropTrustPolicy', () => { + it('drops the policy by id once it reads back unchanged', async () => { + const { post, params } = clientListing([policy()]); + await dropTrustPolicy({ ...params, loaded: policy() }); + expect(post.mock.calls[1][1]).toEqual({ operation: 'drop_oidc_trust', id: 'github-actions-my-app' }); + }); + + it('refuses to drop a policy that was replaced under the same id after the page loaded it', async () => { + const { post, params } = clientListing([policy({ user: 'someone-else', __updatedtime__: 9 })]); + await expect(dropTrustPolicy({ ...params, loaded: policy() })).rejects.toThrow(/changed since this page loaded it/); + expect(post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/integrations/api/instance/oidcTrust/oidcTrust.ts b/src/integrations/api/instance/oidcTrust/oidcTrust.ts new file mode 100644 index 000000000..cac945a3b --- /dev/null +++ b/src/integrations/api/instance/oidcTrust/oidcTrust.ts @@ -0,0 +1,151 @@ +/** + * OIDC trust policies (`system.hdb_oidc_trust`, Harper 5.3+, HarperFast/harper#2171): each lets an + * external CI run trade its OIDC identity token for a one-hour Harper operation token, acting as a + * named user. All three operations are super_user only, enforced in their handlers. Rows reach every + * node through system-table replication, so any node lists the same set. + */ +import { InstanceClientIdConfig } from '@/config/instanceClientConfig'; +import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB'; +import { queryOptions, useMutation } from '@tanstack/react-query'; + +/** The first tagged build with add/list/drop_oidc_trust. */ +export const TRUST_POLICIES_MIN_VERSION = '5.3.0-alpha.1'; + +export function supportsTrustPolicies(version: string | undefined): boolean { + return !!version && wasAReleasedBeforeB(TRUST_POLICIES_MIN_VERSION, version); +} + +/** + * One `list_oidc_trust` row. Harper reports each value as stored rather than coercing it, and marks a + * row the token exchange would refuse with `invalid_reason` — so `operations` and `enabled` may hold + * any shape on such a row. + */ +export interface OidcTrustPolicy { + id: string; + issuer: string; + audience: string; + /** Claim name → the value, or values, the token's claim must equal exactly. */ + claims: Record; + /** The user the exchanged token acts as. */ + user: string; + /** null: the token carries everything the user's role allows. */ + operations: unknown; + enabled: unknown; + invalid_reason?: string; + description?: string | null; + updated_by?: string | null; + __createdtime__?: number; + __updatedtime__?: number; +} + +interface ListOidcTrustResponse { + policies?: unknown; +} + +function isPolicyRow(row: unknown): row is OidcTrustPolicy { + return !!row && typeof row === 'object' && typeof (row as { id?: unknown }).id === 'string'; +} + +async function listOidcTrust({ instanceClient }: InstanceClientIdConfig): Promise { + const { data } = await instanceClient.post('/', { operation: 'list_oidc_trust' }); + return Array.isArray(data?.policies) ? data.policies.filter(isPolicyRow) : []; +} + +export function listOidcTrustQueryOptions(params: InstanceClientIdConfig & { enabled?: boolean }) { + return queryOptions({ + queryKey: [params.entityId, 'list_oidc_trust'] as const, + queryFn: () => listOidcTrust(params), + enabled: params.enabled, + }); +} + +export function isTrustPolicyScoped(policy: OidcTrustPolicy): boolean { + return policy.operations != null; +} + +/** Reported by `list_oidc_trust` but set by Harper, never taken from the request. */ +const REPORTED_ONLY = new Set(['invalid_reason', 'updated_by', '__createdtime__', '__updatedtime__']); + +/** + * The `add_oidc_trust` request that stores `policy` again with only `enabled` changed. The operation + * replaces the whole row, so every stored field goes back — including any this file does not know — + * except nulls, which its validator rejects and which mean absent. A scope that is neither absent nor + * a list of names is refused rather than dropped, since dropping it would store the policy unscoped + * and widen what its token can do. + */ +export function trustPolicyResaveRequest(policy: OidcTrustPolicy, enabled: boolean) { + const { operations } = policy; + if (operations != null && !(Array.isArray(operations) && operations.every((name) => typeof name === 'string'))) { + throw new Error(`Trust policy '${policy.id}' has a malformed operations scope, so it cannot be re-saved.`); + } + const stored: Record = {}; + for (const [field, value] of Object.entries(policy)) { + if (value != null && !REPORTED_ONLY.has(field)) { + stored[field] = value; + } + } + return { ...stored, operation: 'add_oidc_trust', enabled }; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(',')}]`; + } + if (value !== null && typeof value === 'object') { + const record = value as Record; + return `{${ + Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',') + }}`; + } + return JSON.stringify(value) ?? 'null'; +} + +/** + * The policy as Harper holds it now, refused if anything differs from `loaded`: both writes act on a + * row the reader last saw, and acting on a stale copy would undo, or drop, whatever another + * administrator changed in between. A change landing within this one round trip still wins the race; + * closing that needs a conditional write in Harper. + */ +async function rereadUnchanged(params: InstanceClientIdConfig, loaded: OidcTrustPolicy): Promise { + const current = (await listOidcTrust(params)).find((policy) => policy.id === loaded.id); + if (!current) { + throw new Error(`Trust policy '${loaded.id}' no longer exists.`); + } + if (stableJson(current) !== stableJson(loaded)) { + throw new Error(`Trust policy '${loaded.id}' changed since this page loaded it. Review it and try again.`); + } + return current; +} + +export interface TrustPolicyWriteArgs extends InstanceClientIdConfig { + /** The row as the page last showed it. */ + loaded: OidcTrustPolicy; +} + +export async function setTrustPolicyEnabled( + { loaded, enabled, ...params }: TrustPolicyWriteArgs & { enabled: boolean }, +) { + const current = await rereadUnchanged(params, loaded); + const { data } = await params.instanceClient.post<{ message: string }>( + '/', + trustPolicyResaveRequest(current, enabled), + ); + return data; +} + +export function useSetTrustPolicyEnabled() { + return useMutation({ mutationFn: setTrustPolicyEnabled }); +} + +export async function dropTrustPolicy({ loaded, ...params }: TrustPolicyWriteArgs) { + await rereadUnchanged(params, loaded); + const { data } = await params.instanceClient.post<{ message: string }>('/', { + operation: 'drop_oidc_trust', + id: loaded.id, + }); + return data; +} + +export function useDropTrustPolicy() { + return useMutation({ mutationFn: dropTrustPolicy }); +}