From 7087a491f9d69789d96cd1bb3295678e4c298078 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 04:00:46 -0400 Subject: [PATCH 1/3] feat(config): list, disable and drop OIDC trust policies Harper 5.3 lets a CI workflow sign in with its OIDC identity token under a trust policy, and Studio had no way to see those policies. Config now has a Trust Policies page for super users on 5.3+, built on list_oidc_trust and drop_oidc_trust: what each policy trusts, the user it signs in as, what its token may run, and Harper's invalid_reason when it refuses every token. Disable and Enable re-save the policy through add_oidc_trust, which replaces the whole row. The policy is read again first and the write is refused if it changed since the page loaded it, so a stale copy cannot undo another administrator's edit. A malformed scope is never dropped on re-save, since that would widen the token. Refs #1788 Co-Authored-By: Claude Opus 5.5 --- src/features/instance/config/index.tsx | 15 + src/features/instance/config/routes.ts | 10 + .../config/trustPolicies/index.test.tsx | 176 ++++++++++++ .../instance/config/trustPolicies/index.tsx | 257 ++++++++++++++++++ .../api/instance/oidcTrust/oidcTrust.test.ts | 112 ++++++++ .../api/instance/oidcTrust/oidcTrust.ts | 144 ++++++++++ 6 files changed, 714 insertions(+) create mode 100644 src/features/instance/config/trustPolicies/index.test.tsx create mode 100644 src/features/instance/config/trustPolicies/index.tsx create mode 100644 src/integrations/api/instance/oidcTrust/oidcTrust.test.ts create mode 100644 src/integrations/api/instance/oidcTrust/oidcTrust.ts 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, + }, + 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 } }; + } + return { data: state.policies, isFetching: false, 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; +}); + +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/ })); + await waitFor(() => + expect(dropPolicy).toHaveBeenCalledWith(expect.objectContaining({ id: 'github-actions-my-app' })) + ); + await waitFor(() => expect(refetch).toHaveBeenCalled()); + }); + + 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..fb3e004da --- /dev/null +++ b/src/features/instance/config/trustPolicies/index.tsx @@ -0,0 +1,257 @@ +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 (HarperFast/studio#1788): list, disable or enable, and drop. Creating + * one is left to `add_oidc_trust` until the setup flow Studio should share with the CLI is settled. + */ +export function ConfigTrustPoliciesIndex() { + const instanceParams = useInstanceClientIdParams(); + const canManage = useInstanceManagePermission(); + const { data: registrationInfo } = 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(); + const busy = isSetEnabledPending || isDropPending; + + if (!canManage) { + return Only super users can view or change trust policies.; + } + if (supported === undefined) { + return ; + } + 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, id: dropTarget }); + toast.success(`Dropped trust policy '${dropTarget}'`); + setDropTarget(undefined); + } finally { + 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.id)} + /> + ))} +
    + )} + + !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..c15c7c036 --- /dev/null +++ b/src/integrations/api/instance/oidcTrust/oidcTrust.test.ts @@ -0,0 +1,112 @@ +import type { EntityIds } from '@/features/auth/store/authStore'; +import type { AxiosInstance } from 'axios'; +import { describe, expect, it, vi } from 'vitest'; +import { 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('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('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); + }); +}); diff --git a/src/integrations/api/instance/oidcTrust/oidcTrust.ts b/src/integrations/api/instance/oidcTrust/oidcTrust.ts new file mode 100644 index 000000000..43a29fd1b --- /dev/null +++ b/src/integrations/api/instance/oidcTrust/oidcTrust.ts @@ -0,0 +1,144 @@ +/** + * 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; +} + +/** + * 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; absent ones are omitted because its + * validator takes no nulls. A scope that is neither absent nor a list of names is refused rather than + * omitted, since omitting it would store the policy unscoped and widen what its token can do. + */ +export function trustPolicyResaveRequest(policy: OidcTrustPolicy, enabled: boolean) { + const { operations, description } = 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.`); + } + return { + operation: 'add_oidc_trust', + id: policy.id, + issuer: policy.issuer, + audience: policy.audience, + claims: policy.claims, + user: policy.user, + ...(operations != null ? { operations } : {}), + enabled, + ...(typeof description === 'string' ? { description } : {}), + }; +} + +function storedShape(policy: OidcTrustPolicy): string { + return JSON.stringify([ + policy.__updatedtime__ ?? null, + policy.issuer, + policy.audience, + policy.claims, + policy.user, + policy.operations ?? null, + policy.enabled, + policy.description ?? null, + ]); +} + +export interface SetTrustPolicyEnabledArgs extends InstanceClientIdConfig { + /** The row as the page last showed it. */ + loaded: OidcTrustPolicy; + enabled: boolean; +} + +/** + * Re-reads the policy first and refuses if it changed since `loaded`: the write replaces the row, so + * re-saving a stale copy would undo whatever another administrator changed in between. A change + * landing within this one round trip still loses; closing that needs a conditional write in Harper. + */ +export async function setTrustPolicyEnabled({ loaded, enabled, ...params }: SetTrustPolicyEnabledArgs) { + const current = (await listOidcTrust(params)).find((policy) => policy.id === loaded.id); + if (!current) { + throw new Error(`Trust policy '${loaded.id}' no longer exists.`); + } + if (storedShape(current) !== storedShape(loaded)) { + throw new Error(`Trust policy '${loaded.id}' changed since this page loaded it. Review it and try again.`); + } + const { data } = await params.instanceClient.post<{ message: string }>( + '/', + trustPolicyResaveRequest(current, enabled), + ); + return data; +} + +export function useSetTrustPolicyEnabled() { + return useMutation({ mutationFn: setTrustPolicyEnabled }); +} + +export interface DropTrustPolicyArgs extends InstanceClientIdConfig { + id: string; +} + +async function dropOidcTrust({ instanceClient, id }: DropTrustPolicyArgs) { + const { data } = await instanceClient.post<{ message: string }>('/', { operation: 'drop_oidc_trust', id }); + return data; +} + +export function useDropTrustPolicy() { + return useMutation({ mutationFn: dropOidcTrust }); +} From f03416469a701ff6857f084105a2a474634770c1 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 04:01:40 -0400 Subject: [PATCH 2/3] fix(applications): the CLI tab follows create-harper's harper login flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Application → CLI still passed --deploymentUsername and told people to save their cluster password in .env. create-harper dropped both in 1.7.0: it takes the cluster URL, offers to run harper login, and keeps the token in the home directory. The steps now say that, and the deploy step says it uses that login. Refs #1788 Co-Authored-By: Claude Opus 5.5 --- .../NewApplication/useCLISteps.test.ts | 29 +++++++ .../components/NewApplication/useCLISteps.tsx | 78 ++++++++++--------- 2 files changed, 71 insertions(+), 36 deletions(-) create mode 100644 src/features/instance/applications/components/NewApplication/useCLISteps.test.ts 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]); } From 48cb0d790472bd930993de4d2a0540d4f548a6f7 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 04:12:31 -0400 Subject: [PATCH 3/3] fix(config): trust policy writes act only on the row the page showed From review of the trust policies page: - Drop re-reads the policy too, and refuses if it changed since the page loaded it, so a confirmation can't drop a policy recreated under the same id. - The stale check compares the whole row, keys sorted, rather than a fixed field list, and a re-save sends back every stored field, including any a newer Harper adds, so toggling never erases one. - Actions stay disabled while the list refetches after a write. - An unreadable Harper version says so instead of loading forever. - The drop confirmation names the policy. Refs #1788 Co-Authored-By: Claude Opus 5.5 --- .../config/trustPolicies/index.test.tsx | 33 ++++++- .../instance/config/trustPolicies/index.tsx | 29 +++--- .../api/instance/oidcTrust/oidcTrust.test.ts | 34 ++++++- .../api/instance/oidcTrust/oidcTrust.ts | 95 ++++++++++--------- 4 files changed, 127 insertions(+), 64 deletions(-) diff --git a/src/features/instance/config/trustPolicies/index.test.tsx b/src/features/instance/config/trustPolicies/index.test.tsx index f61ce7f58..3ee861bba 100644 --- a/src/features/instance/config/trustPolicies/index.test.tsx +++ b/src/features/instance/config/trustPolicies/index.test.tsx @@ -12,6 +12,8 @@ const { state, setEnabled, dropPolicy, refetch, queryOptionsSeen } = vi.hoisted( 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' })), @@ -24,9 +26,12 @@ vi.mock('@tanstack/react-query', async (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 } }; + return { + data: state.version === undefined ? undefined : { version: state.version }, + isError: state.versionError, + }; } - return { data: state.policies, isFetching: false, isError: state.isError, refetch }; + return { data: state.policies, isFetching: state.isFetching, isError: state.isError, refetch }; }, })); vi.mock('@/config/useInstanceClient', () => ({ @@ -63,6 +68,8 @@ beforeEach(() => { state.version = '5.3.0'; state.policies = []; state.isError = false; + state.isFetching = false; + state.versionError = false; }); afterEach(() => { @@ -159,12 +166,28 @@ describe('ConfigTrustPoliciesIndex', () => { expect(dropPolicy).not.toHaveBeenCalled(); const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: /Drop/ })); - await waitFor(() => - expect(dropPolicy).toHaveBeenCalledWith(expect.objectContaining({ id: 'github-actions-my-app' })) - ); + // 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(); diff --git a/src/features/instance/config/trustPolicies/index.tsx b/src/features/instance/config/trustPolicies/index.tsx index fb3e004da..d419fd5f4 100644 --- a/src/features/instance/config/trustPolicies/index.tsx +++ b/src/features/instance/config/trustPolicies/index.tsx @@ -21,14 +21,13 @@ 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 (HarperFast/studio#1788): list, disable or enable, and drop. Creating - * one is left to `add_oidc_trust` until the setup flow Studio should share with the CLI is settled. - */ +/** The cluster's OIDC trust policies: list, disable or enable, and drop. */ export function ConfigTrustPoliciesIndex() { const instanceParams = useInstanceClientIdParams(); const canManage = useInstanceManagePermission(); - const { data: registrationInfo } = useQuery(getRegistrationInfoQueryOptions(instanceParams)); + 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 }), @@ -37,14 +36,17 @@ export function ConfigTrustPoliciesIndex() { const { mutateAsync: setEnabled, isPending: isSetEnabledPending } = useSetTrustPolicyEnabled(); const { mutateAsync: dropPolicy, isPending: isDropPending } = useDropTrustPolicy(); - const [dropTarget, setDropTarget] = useState(); - const busy = isSetEnabledPending || isDropPending; + 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 ; + return isVersionError + ? This instance's Harper version could not be read, so its trust policies are not shown. + : ; } if (!supported) { return ( @@ -70,10 +72,10 @@ export function ConfigTrustPoliciesIndex() { return; } try { - await dropPolicy({ ...instanceParams, id: dropTarget }); - toast.success(`Dropped trust policy '${dropTarget}'`); - setDropTarget(undefined); + await dropPolicy({ ...instanceParams, loaded: dropTarget }); + toast.success(`Dropped trust policy '${dropTarget.id}'`); } finally { + setDropTarget(undefined); await refetch(); } }; @@ -121,15 +123,14 @@ export function ConfigTrustPoliciesIndex() { policy={policy} disabled={busy} onToggle={(enabled) => void onToggle(policy, enabled).catch(() => undefined)} - onDrop={() => setDropTarget(policy.id)} + onDrop={() => setDropTarget(policy)} /> ))} )} !open && setDropTarget(undefined)} deletionConfirmed={() => void onDropConfirmed().catch(() => undefined)} diff --git a/src/integrations/api/instance/oidcTrust/oidcTrust.test.ts b/src/integrations/api/instance/oidcTrust/oidcTrust.test.ts index c15c7c036..24dc79aa9 100644 --- a/src/integrations/api/instance/oidcTrust/oidcTrust.test.ts +++ b/src/integrations/api/instance/oidcTrust/oidcTrust.test.ts @@ -1,7 +1,13 @@ import type { EntityIds } from '@/features/auth/store/authStore'; import type { AxiosInstance } from 'axios'; import { describe, expect, it, vi } from 'vitest'; -import { OidcTrustPolicy, setTrustPolicyEnabled, supportsTrustPolicies, trustPolicyResaveRequest } from './oidcTrust'; +import { + dropTrustPolicy, + OidcTrustPolicy, + setTrustPolicyEnabled, + supportsTrustPolicies, + trustPolicyResaveRequest, +} from './oidcTrust'; function policy(overrides: Partial = {}): OidcTrustPolicy { return { @@ -65,6 +71,11 @@ describe('trustPolicyResaveRequest', () => { 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/, @@ -102,6 +113,13 @@ describe('setTrustPolicyEnabled', () => { 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( @@ -110,3 +128,17 @@ describe('setTrustPolicyEnabled', () => { 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 index 43a29fd1b..cac945a3b 100644 --- a/src/integrations/api/instance/oidcTrust/oidcTrust.ts +++ b/src/integrations/api/instance/oidcTrust/oidcTrust.ts @@ -63,62 +63,69 @@ 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; absent ones are omitted because its - * validator takes no nulls. A scope that is neither absent nor a list of names is refused rather than - * omitted, since omitting it would store the policy unscoped and widen what its token can do. + * 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, description } = policy; + 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.`); } - return { - operation: 'add_oidc_trust', - id: policy.id, - issuer: policy.issuer, - audience: policy.audience, - claims: policy.claims, - user: policy.user, - ...(operations != null ? { operations } : {}), - enabled, - ...(typeof description === 'string' ? { description } : {}), - }; -} - -function storedShape(policy: OidcTrustPolicy): string { - return JSON.stringify([ - policy.__updatedtime__ ?? null, - policy.issuer, - policy.audience, - policy.claims, - policy.user, - policy.operations ?? null, - policy.enabled, - policy.description ?? null, - ]); + 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 }; } -export interface SetTrustPolicyEnabledArgs extends InstanceClientIdConfig { - /** The row as the page last showed it. */ - loaded: OidcTrustPolicy; - enabled: boolean; +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'; } /** - * Re-reads the policy first and refuses if it changed since `loaded`: the write replaces the row, so - * re-saving a stale copy would undo whatever another administrator changed in between. A change - * landing within this one round trip still loses; closing that needs a conditional write in Harper. + * 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. */ -export async function setTrustPolicyEnabled({ loaded, enabled, ...params }: SetTrustPolicyEnabledArgs) { +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 (storedShape(current) !== storedShape(loaded)) { + 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), @@ -130,15 +137,15 @@ export function useSetTrustPolicyEnabled() { return useMutation({ mutationFn: setTrustPolicyEnabled }); } -export interface DropTrustPolicyArgs extends InstanceClientIdConfig { - id: string; -} - -async function dropOidcTrust({ instanceClient, id }: DropTrustPolicyArgs) { - const { data } = await instanceClient.post<{ message: string }>('/', { operation: 'drop_oidc_trust', id }); +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: dropOidcTrust }); + return useMutation({ mutationFn: dropTrustPolicy }); }