From 7cd15811d80b959d155b7383bb5badc775e1fc05 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 2 Jul 2026 12:39:46 -0400 Subject: [PATCH 1/4] feat(secrets): cluster secrets page on the hdb_secret store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config > Secrets manages the replicated system.hdb_secret table (harper#1554; Pro key custody in harper-pro#512, tracked by harper-pro#166): named, envelope-encrypted rows on the cluster itself, edited through the per-instance operations API and scoped to applications via grants. - list_secrets / set_secret / delete_secret / grant_secret / revoke_secret / get_secrets_public_key hooks on the instance operations client. - Values are encrypted in the browser (enc:v1 via lib/crypto/envSecret.ts, which harper#1554 ported into core as its envelope codec) and can never be read back. Rotation-aware: the public key is cached minutes not forever, and a kid-mismatch rejection drops the cached key, re-encrypts, and retries once. - Grants editor in the edit dialog (only granted applications receive a secret at load time) and per-row warnings for rows whose kid no longer matches the cluster's custody key. - Degrades gracefully without key custody: the list stays browsable, a banner explains why nothing can be encrypted, and add/edit is disabled. - Nav entry is version-gated only (>= 5.2.0 placeholder, matching harper#1554's upgrade directive) — the store ships in core, so it is not restricted to Fabric-managed clusters. Builds on the shared SecretsManager / SecretModals components from the .env editor PR underneath this one. Hook coverage runs against real WebCrypto RSA keys, including the rotation retry re-encrypting under the newly served key. Co-Authored-By: Claude Fable 5 --- src/features/instance/config/index.tsx | 21 ++- src/features/instance/config/routes.ts | 17 ++ .../config/secrets/SecretGrantsEditor.tsx | 115 ++++++++++++ .../instance/config/secrets/index.tsx | 113 ++++++++++++ .../api/instance/secrets/secrets.test.tsx | 172 ++++++++++++++++++ .../api/instance/secrets/secrets.ts | 170 +++++++++++++++++ src/lib/crypto/envSecret.test.ts | 72 ++++++++ src/lib/crypto/envSecret.ts | 107 +++++++++++ 8 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 src/features/instance/config/secrets/SecretGrantsEditor.tsx create mode 100644 src/features/instance/config/secrets/index.tsx create mode 100644 src/integrations/api/instance/secrets/secrets.test.tsx create mode 100644 src/integrations/api/instance/secrets/secrets.ts create mode 100644 src/lib/crypto/envSecret.test.ts create mode 100644 src/lib/crypto/envSecret.ts diff --git a/src/features/instance/config/index.tsx b/src/features/instance/config/index.tsx index 0b8d4966e..b9699656b 100644 --- a/src/features/instance/config/index.tsx +++ b/src/features/instance/config/index.tsx @@ -8,7 +8,7 @@ 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 { GlobeIcon, HandshakeIcon, KeyIcon, PieChartIcon, RocketIcon, ShieldCheckIcon, UsersIcon } from 'lucide-react'; +import { GlobeIcon, HandshakeIcon, KeyIcon, LockIcon, PieChartIcon, RocketIcon, ShieldCheckIcon, UsersIcon } from 'lucide-react'; import { ReactNode, Suspense } from 'react'; const sharedClasses = 'flex items-center p-2 rounded-lg group'; @@ -24,6 +24,9 @@ export function ConfigIndex() { const { version }: RegistrationInfoResponse = useLoaderData({ strict: false }); const certsAvailable = wasAReleasedBeforeB('4.6.0', version); const deploymentsAvailable = wasAReleasedBeforeB('5.1.0', version); + // The hdb_secret store and its operations (list_secrets / set_secret / …) ship in Harper 5.2 + // (harper#1554's upgrade directive is tagged 5.2.0 — revisit if it ships in a different release). + const secretsSupported = wasAReleasedBeforeB('5.2.0', version); const { clusterId } = params; const canManage = useInstanceManagePermission(); @@ -102,6 +105,22 @@ export function ConfigIndex() { )} + { + /* Secrets (the replicated hdb_secret store): any instance or cluster on a supported + version — key custody may be file-based (self-hosted) or injected (Fabric). */ + } + {secretsSupported && ( +
  • + + Secrets + +
  • + )} ); diff --git a/src/features/instance/config/routes.ts b/src/features/instance/config/routes.ts index 2b5b0b1eb..ee2b40bef 100644 --- a/src/features/instance/config/routes.ts +++ b/src/features/instance/config/routes.ts @@ -4,6 +4,7 @@ import { ConfigDomainsIndex } from '@/features/instance/config/domains'; import { ConfigIndex } from '@/features/instance/config/index'; import { ConfigOverviewIndex } from '@/features/instance/config/overview'; import { ConfigRolesIndex } from '@/features/instance/config/roles'; +import { ConfigSecretsIndex } from '@/features/instance/config/secrets'; import { ConfigSSHKeysIndex } from '@/features/instance/config/sshKeys'; import { ConfigUsersIndex } from '@/features/instance/config/users'; import { createInstanceLayoutRoute } from '@/features/instance/instanceLayoutRoute'; @@ -85,6 +86,19 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType instanceConfigRoute, + path: 'secrets', + head: () => ({ meta: [{ title: 'Secrets — Harper Fabric' }] }), + component: ConfigSecretsIndex, + }); + const instanceConfigSecretRoute = createRoute({ + getParentRoute: () => instanceConfigRoute, + path: 'secrets/$secretName', + head: () => ({ meta: [{ title: 'Secrets — Harper Fabric' }] }), + component: ConfigSecretsIndex, + }); + const instanceConfigCertificatesRoute = createRoute({ getParentRoute: () => instanceConfigRoute, path: 'certificates', @@ -115,6 +129,9 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType void; +}) { + const instanceParams = useInstanceClientIdParams(); + const { mutateAsync: grantSecret, isPending: isGranting } = useGrantSecret(); + const { mutateAsync: revokeSecret, isPending: isRevoking } = useRevokeSecret(); + const busy = isGranting || isRevoking; + + // The mutation responses carry the resulting grants, so the chips track server truth. + const [grants, setGrants] = useState(initialGrants); + const [component, setComponent] = useState(''); + + const onGrantClick = useCallback(async () => { + const target = component.trim(); + if (!target) { + return; + } + try { + const response = await grantSecret({ ...instanceParams, name, component: target }); + setGrants(response.grants); + setComponent(''); + onChanged?.(); + } catch (error) { + toast.error(String(error)); + } + }, [component, grantSecret, instanceParams, name, onChanged]); + + const onRevokeClick = useCallback(async (target: string) => { + try { + const response = await revokeSecret({ ...instanceParams, name, component: target }); + setGrants(response.grants); + onChanged?.(); + } catch (error) { + toast.error(String(error)); + } + }, [revokeSecret, instanceParams, name, onChanged]); + + // This editor lives inside the value form — Enter must grant, not submit a value replacement. + const onComponentKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + void onGrantClick(); + } + }, [onGrantClick]); + + return ( +
    + Granted applications +

    + Only granted applications receive this secret in their environment. Changes apply immediately. +

    + {grants.length > 0 && ( +
    + {grants.map((granted) => ( + + {granted} + + + ))} +
    + )} +
    + setComponent(event.target.value)} + onKeyDown={onComponentKeyDown} + disabled={busy} + /> + +
    +
    + ); +} diff --git a/src/features/instance/config/secrets/index.tsx b/src/features/instance/config/secrets/index.tsx new file mode 100644 index 000000000..5bf5a7358 --- /dev/null +++ b/src/features/instance/config/secrets/index.tsx @@ -0,0 +1,113 @@ +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { SecretGrantsEditor } from '@/features/instance/config/secrets/SecretGrantsEditor'; +import { SecretRow, SecretsManager } from '@/features/instance/secrets/SecretsManager'; +import { + listSecretsQueryOptions, + SecretMetadata, + secretsPublicKeyQueryOptions, + useDeleteSecret, + useSetSecret, +} from '@/integrations/api/instance/secrets/secrets'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { TriangleAlertIcon } from 'lucide-react'; +import { useCallback, useMemo } from 'react'; + +/** + * Cluster secrets (the replicated `system.hdb_secret` store, harper#1554 / harper-pro#166): + * named, envelope-encrypted values managed through the instance operations API and scoped to + * applications via grants. Values are encrypted in the browser and can never be read back. + */ +export function ConfigSecretsIndex() { + const navigate = useNavigate(); + const { secretName }: { secretName?: string } = useParams({ strict: false }); + const instanceParams = useInstanceClientIdParams(); + const { data, refetch, isFetching } = useQuery(listSecretsQueryOptions(instanceParams)); + // Without registered key custody (the Pro secretCustody component) the node has no secrets + // key: nothing can be encrypted, so the store is browsable read-only. + const publicKeyQuery = useQuery(secretsPublicKeyQueryOptions(instanceParams)); + + const secrets = data?.secrets; + const rows = useMemo( + () => (secrets ?? []).map((secret) => ({ name: secret.name, warning: warningFor(secret, data) })), + [secrets, data], + ); + const selectedName = useMemo(() => secrets?.find((s) => s.name === secretName)?.name, [secrets, secretName]); + + const onSelectName = useCallback( + (next: string | undefined) => { + const parts = [secretName ? '..' : '', next].filter(Boolean); + void navigate({ to: parts.join('/') }); + }, + [navigate, secretName], + ); + + const { mutateAsync: setSecret } = useSetSecret(); + const { mutateAsync: deleteSecret } = useDeleteSecret(); + + const onSet = useCallback(async (name: string, value: string) => { + await setSecret({ ...instanceParams, name, value }); + await refetch(); + }, [setSecret, instanceParams, refetch]); + + const onDelete = useCallback(async (name: string) => { + await deleteSecret({ ...instanceParams, name }); + await refetch(); + }, [deleteSecret, instanceParams, refetch]); + + return ( + <> + {publicKeyQuery.isError && ( +

    + + + Secrets are read-only right now: this cluster has no secrets key, so values can't be encrypted. Key custody + is provided by the Harper secret-custody component — once it's active, refresh this page. + +

    + )} + { + const secret = secrets?.find((s) => s.name === name); + return ( + secret && ( + void refetch()} + /> + ) + ); + }} + /> + + ); +} + +/** A per-row caution for stored secrets that may not decrypt at load time. */ +function warningFor(secret: SecretMetadata, data: { custody_fingerprint: string | null } | undefined) { + // Without custody there is no key identity to compare against — every row would "mismatch". + if (!data?.custody_fingerprint) { + return undefined; + } + if (!secret.kid_matches_custody) { + return "Encrypted under a different key than the cluster's current secrets key — it may fail to decrypt at load time. Set a new value to re-encrypt."; + } + if (secret.unverified) { + return 'Stored without key-identity verification.'; + } + return undefined; +} diff --git a/src/integrations/api/instance/secrets/secrets.test.tsx b/src/integrations/api/instance/secrets/secrets.test.tsx new file mode 100644 index 000000000..1d27cd2e1 --- /dev/null +++ b/src/integrations/api/instance/secrets/secrets.test.tsx @@ -0,0 +1,172 @@ +/** + * @vitest-environment jsdom + */ +import { EntityIds } from '@/features/auth/store/authStore'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { AxiosInstance } from 'axios'; +import { webcrypto } from 'node:crypto'; +import { PropsWithChildren } from 'react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { secretsPublicKeyQueryOptions, useDeleteSecret, useSetSecret } from './secrets'; + +/* + Exercises the hdb_secret client flow against a mocked operations client and a REAL RSA keypair: + set_secret must carry an `enc:v1:` envelope whose sealed kid matches the served fingerprint (the + plaintext never appears in the request), and a kid-mismatch rejection (custody key rotated under + our cached copy) must refetch the key and re-encrypt exactly once. + */ + +// jsdom has no SubtleCrypto; Node's WebCrypto implements the same interface. +beforeAll(() => { + if (!globalThis.crypto?.subtle) { + Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true }); + } +}); + +afterEach(() => vi.clearAllMocks()); + +async function generateKey() { + const keyPair = await webcrypto.subtle.generateKey( + { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['encrypt', 'decrypt'], + ); + const der = new Uint8Array(await webcrypto.subtle.exportKey('spki', keyPair.publicKey)); + const b64 = btoa(String.fromCharCode(...der)); + const pem = `-----BEGIN PUBLIC KEY-----\n${b64.replace(/(.{64})/g, '$1\n')}\n-----END PUBLIC KEY-----\n`; + const fingerprintBytes = new Uint8Array(await webcrypto.subtle.digest('SHA-256', der)); + const fingerprint = Array.from(fingerprintBytes).map((b) => b.toString(16).padStart(2, '0')).join(''); + return { pem, fingerprint }; +} + +function decodeEnvelope(envelope: string): { kid: string } { + expect(envelope.startsWith('enc:v1:')).toBe(true); + const b64 = envelope.slice('enc:v1:'.length).replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(atob(b64)); +} + +function harness() { + const post = vi.fn(); + const instanceClient = { post } as unknown as AxiosInstance; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const params = { instanceClient, entityId: 'test-cluster' as EntityIds }; + return { post, queryClient, wrapper, params }; +} + +describe('useSetSecret', () => { + it('encrypts client-side and posts an envelope sealed with the served key', async () => { + const { post, wrapper, params } = harness(); + const key = await generateKey(); + post.mockImplementation((_url: string, body: { operation: string }) => { + if (body.operation === 'get_secrets_public_key') { + return Promise.resolve({ data: { public_key: key.pem, fingerprint: key.fingerprint } }); + } + if (body.operation === 'set_secret') { + return Promise.resolve({ data: { name: 'API_KEY', kid: key.fingerprint, created: true } }); + } + return Promise.reject(new Error(`Unexpected operation ${body.operation}`)); + }); + + const { result } = renderHook(() => useSetSecret(), { wrapper }); + const response = await result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'hunter2' }); + + expect(response.created).toBe(true); + const setCalls = post.mock.calls.filter(([, body]) => body.operation === 'set_secret'); + expect(setCalls).toHaveLength(1); + const [, setBody] = setCalls[0]; + expect(setBody.name).toBe('API_KEY'); + expect(setBody.value).toBeUndefined(); // plaintext never leaves the browser + expect(JSON.stringify(setBody)).not.toContain('hunter2'); + expect(decodeEnvelope(setBody.envelope).kid).toBe(key.fingerprint); + }); + + it('refetches the public key and re-encrypts once on a kid mismatch (key rotation)', async () => { + const { post, wrapper, params } = harness(); + const staleKey = await generateKey(); + const activeKey = await generateKey(); + let servedKey = staleKey; + post.mockImplementation((_url: string, body: { operation: string; envelope?: string }) => { + if (body.operation === 'get_secrets_public_key') { + return Promise.resolve({ data: { public_key: servedKey.pem, fingerprint: servedKey.fingerprint } }); + } + if (body.operation === 'set_secret') { + const { kid } = decodeEnvelope(body.envelope!); + if (kid !== activeKey.fingerprint) { + // The moment the stale envelope is rejected, the node serves the rotated key. + servedKey = activeKey; + return Promise.reject({ + response: { + data: { error: `Secret envelope kid '${kid}' does not match this cluster's secrets key` }, + }, + }); + } + return Promise.resolve({ data: { name: 'API_KEY', kid, created: false } }); + } + return Promise.reject(new Error(`Unexpected operation ${body.operation}`)); + }); + + const { result } = renderHook(() => useSetSecret(), { wrapper }); + const response = await result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'rotated' }); + + expect(response.kid).toBe(activeKey.fingerprint); + const setCalls = post.mock.calls.filter(([, body]) => body.operation === 'set_secret'); + expect(setCalls).toHaveLength(2); // stale attempt + one re-encrypted retry + expect(decodeEnvelope(setCalls[1][1].envelope).kid).toBe(activeKey.fingerprint); + }); + + it('surfaces the operation error message (not Axios noise) when the retry also fails', async () => { + const { post, wrapper, params } = harness(); + const key = await generateKey(); + post.mockImplementation((_url: string, body: { operation: string }) => { + if (body.operation === 'get_secrets_public_key') { + return Promise.resolve({ data: { public_key: key.pem, fingerprint: key.fingerprint } }); + } + return Promise.reject({ response: { data: { error: 'secrets custody is not initialized on this node' } } }); + }); + + const { result } = renderHook(() => useSetSecret(), { wrapper }); + await expect(result.current.mutateAsync({ ...params, name: 'API_KEY', value: 'x' })).rejects.toThrow( + 'secrets custody is not initialized on this node', + ); + }); + + it('caches the public key between writes', async () => { + const { post, wrapper, params, queryClient } = harness(); + const key = await generateKey(); + post.mockImplementation((_url: string, body: { operation: string }) => { + if (body.operation === 'get_secrets_public_key') { + return Promise.resolve({ data: { public_key: key.pem, fingerprint: key.fingerprint } }); + } + return Promise.resolve({ data: { name: 'X', kid: key.fingerprint, created: true } }); + }); + + const { result } = renderHook(() => useSetSecret(), { wrapper }); + await result.current.mutateAsync({ ...params, name: 'A', value: '1' }); + await result.current.mutateAsync({ ...params, name: 'B', value: '2' }); + + const keyFetches = post.mock.calls.filter(([, body]) => body.operation === 'get_secrets_public_key'); + expect(keyFetches).toHaveLength(1); + // And the cached entry is the one ensureQueryData used. + await waitFor(() => expect(queryClient.getQueryData(secretsPublicKeyQueryOptions(params).queryKey)).toBeTruthy()); + }); +}); + +describe('useDeleteSecret', () => { + it('posts delete_secret and surfaces the body error message on failure', async () => { + const { post, wrapper, params } = harness(); + post.mockResolvedValueOnce({ data: { message: "Successfully deleted secret 'API_KEY'" } }); + + const { result } = renderHook(() => useDeleteSecret(), { wrapper }); + await result.current.mutateAsync({ ...params, name: 'API_KEY' }); + expect(post).toHaveBeenCalledWith('/', { operation: 'delete_secret', name: 'API_KEY' }); + + post.mockRejectedValueOnce({ response: { data: { error: "No secret found with name 'NOPE'" } } }); + await expect(result.current.mutateAsync({ ...params, name: 'NOPE' })).rejects.toThrow( + "No secret found with name 'NOPE'", + ); + }); +}); diff --git a/src/integrations/api/instance/secrets/secrets.ts b/src/integrations/api/instance/secrets/secrets.ts new file mode 100644 index 000000000..e2cb942a8 --- /dev/null +++ b/src/integrations/api/instance/secrets/secrets.ts @@ -0,0 +1,170 @@ +/** + * Cluster secrets — the replicated `system.hdb_secret` store (harper#1554; key custody in + * harper-pro#512, tracked by harper-pro#166). + * + * Secrets are named, envelope-encrypted rows on the cluster itself, managed through the ordinary + * per-instance operations API. Values are encrypted **in the browser** (`enc:v1` envelope against + * the cluster secrets public key) before they leave, so plaintext never reaches the operations + * API, the operation log, or disk — and can never be read back. Rows replicate to every node via + * normal system-table replication; at load time core materializes a secret only into components + * listed in its `grants`. + * + * Without a registered key custody provider (the Pro secretCustody component), the node has no + * public key — `get_secrets_public_key` fails with a clean error and nothing can be encrypted. + * `list_secrets` still works, so the store is browsable read-only in that state. + */ +import { InstanceClientIdConfig } from '@/config/instanceClientConfig'; +import { encryptEnvelope } from '@/lib/crypto/envSecret'; +import { QueryClient, queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'; + +/** `get_secrets_public_key` — the cluster key clients encrypt against. */ +export interface SecretsPublicKey { + public_key: string; + fingerprint: string; +} + +/** One `list_secrets` row: metadata only — never envelopes, never values. */ +export interface SecretMetadata { + name: string; + /** Fingerprint of the key the stored envelope was encrypted under (null for kid-less rows). */ + kid: string | null; + /** Component names allowed to receive this secret at load time. */ + grants: string[]; + metadata: Record; + /** Stored without key-identity verification (kid-less envelope, or no custody at write time). */ + unverified: boolean; + updated_by: string | null; + __createdtime__: number; + __updatedtime__: number; + /** False when the row was encrypted under a different key than the node's current one. */ + kid_matches_custody: boolean; +} + +export interface ListSecretsResponse { + secrets: SecretMetadata[]; + /** The node's current custody key fingerprint; null when no custody is registered. */ + custody_fingerprint: string | null; +} + +/** Operations API errors carry the message in the response body; surface that, not the Axios noise. */ +function operationErrorMessage(error: unknown): string { + const body = (error as { response?: { data?: { error?: string; message?: string } } })?.response?.data; + return body?.error ?? body?.message ?? String(error); +} + +async function getSecretsPublicKey({ instanceClient }: InstanceClientIdConfig): Promise { + const { data } = await instanceClient.post('/', { operation: 'get_secrets_public_key' }); + return data; +} + +export function secretsPublicKeyQueryOptions(params: InstanceClientIdConfig) { + return queryOptions({ + queryKey: [params.entityId, 'get_secrets_public_key'] as const, + queryFn: () => getSecretsPublicKey(params), + // The custody key can rotate (kid map), so refresh periodically instead of caching forever. + // Failure is a state, not a blip: without custody the node simply has no key. + staleTime: 5 * 60_000, + retry: false, + }); +} + +async function listSecrets({ instanceClient }: InstanceClientIdConfig): Promise { + const { data } = await instanceClient.post('/', { operation: 'list_secrets' }); + return data; +} + +export function listSecretsQueryOptions(params: InstanceClientIdConfig) { + return queryOptions({ + queryKey: [params.entityId, 'list_secrets'] as const, + queryFn: () => listSecrets(params), + }); +} + +export interface SetSecretArgs extends InstanceClientIdConfig { + name: string; + value: string; +} + +async function encryptAndSetSecret(queryClient: QueryClient, args: SetSecretArgs, isRetry: boolean): Promise<{ + name: string; + kid: string | null; + created: boolean; +}> { + // Encrypt against the cluster public key so the plaintext value never leaves the browser. The + // envelope's sealed kid tells the cluster which key it was encrypted under. + const { public_key, fingerprint } = await queryClient.ensureQueryData(secretsPublicKeyQueryOptions(args)); + const envelope = await encryptEnvelope(args.value, public_key, fingerprint); + try { + const { data } = await args.instanceClient.post('/', { + operation: 'set_secret', + name: args.name, + envelope, + }); + return data; + } catch (error) { + const message = operationErrorMessage(error); + // A kid mismatch means our cached public key is stale (the custody key rotated). Drop the + // cached key (ensureQueryData returns stale entries, so invalidation isn't enough) and + // re-encrypt once; anything else (or a second mismatch) surfaces to the caller. + if (!isRetry && message.includes('does not match')) { + queryClient.removeQueries({ queryKey: secretsPublicKeyQueryOptions(args).queryKey }); + return encryptAndSetSecret(queryClient, args, true); + } + throw new Error(message); + } +} + +export function useSetSecret() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (args: SetSecretArgs) => encryptAndSetSecret(queryClient, args, false), + }); +} + +export interface DeleteSecretArgs extends InstanceClientIdConfig { + name: string; +} + +async function deleteSecret({ instanceClient, name }: DeleteSecretArgs): Promise<{ message: string }> { + try { + const { data } = await instanceClient.post('/', { operation: 'delete_secret', name }); + return data; + } catch (error) { + throw new Error(operationErrorMessage(error)); + } +} + +export function useDeleteSecret() { + return useMutation({ mutationFn: deleteSecret }); +} + +export interface GrantSecretArgs extends InstanceClientIdConfig { + name: string; + component: string; +} + +export interface GrantSecretResponse { + name: string; + grants: string[]; + changed: boolean; +} + +async function mutateGrant( + operation: 'grant_secret' | 'revoke_secret', + { instanceClient, name, component }: GrantSecretArgs, +): Promise { + try { + const { data } = await instanceClient.post('/', { operation, name, component }); + return data; + } catch (error) { + throw new Error(operationErrorMessage(error)); + } +} + +export function useGrantSecret() { + return useMutation({ mutationFn: (args: GrantSecretArgs) => mutateGrant('grant_secret', args) }); +} + +export function useRevokeSecret() { + return useMutation({ mutationFn: (args: GrantSecretArgs) => mutateGrant('revoke_secret', args) }); +} diff --git a/src/lib/crypto/envSecret.test.ts b/src/lib/crypto/envSecret.test.ts new file mode 100644 index 000000000..6900c1ee2 --- /dev/null +++ b/src/lib/crypto/envSecret.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment node +// Runs in the node env so Web Crypto (globalThis.crypto.subtle, used by envSecret.ts) and node:crypto +// (used here to stand in for the backend decrypt) are both available. +import { + constants, + createDecipheriv, + createHash, + createPublicKey, + generateKeyPairSync, + privateDecrypt, +} from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { encryptEnvelope, ENV_ENCRYPTED_PREFIX, fingerprintOf, isEncryptedEnvValue } from './envSecret'; + +// Mirrors the backend enc:v1 decrypt (harper-pro envSecretCrypto / central-manager deploymentSecrets): +// RSA-OAEP(SHA-256) unwraps the AES key, AES-256-GCM decrypts the value. +function backendDecrypt(value: string, privateKeyPem: string): string { + const env = JSON.parse(Buffer.from(value.slice(ENV_ENCRYPTED_PREFIX.length), 'base64url').toString('utf8')); + const aesKey = privateDecrypt( + { key: privateKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, + Buffer.from(env.k, 'base64'), + ); + const decipher = createDecipheriv('aes-256-gcm', aesKey, Buffer.from(env.iv, 'base64')); + decipher.setAuthTag(Buffer.from(env.tag, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(env.ct, 'base64')), decipher.final()]).toString('utf8'); +} + +// 2048-bit key keeps the test fast; the envelope code paths are identical at any RSA size. +const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); +const publicKeyPem = publicKey as unknown as string; + +describe('envSecret (browser enc:v1)', () => { + it('fingerprintOf matches the backend (sha256 of DER SPKI)', async () => { + const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }); + const expected = createHash('sha256').update(der).digest('hex'); + expect(await fingerprintOf(publicKeyPem)).toBe(expected); + }); + + it('encrypts values the backend can decrypt (round-trip interop)', async () => { + const kid = await fingerprintOf(publicKeyPem); + const samples = [ + 'sk-1234567890', + 'p@ss w#rd"with\'quotes', + 'multi\nline\nvalue', + 'unicode: café 🔐 日本語', + '-----BEGIN PRIVATE KEY-----\n' + 'A'.repeat(1500) + '\n-----END PRIVATE KEY-----\n', + '', + ]; + for (const value of samples) { + const envelope = await encryptEnvelope(value, publicKeyPem, kid); + expect(isEncryptedEnvValue(envelope)).toBe(true); + expect(backendDecrypt(envelope, privateKey as unknown as string)).toBe(value); + } + }); + + it('produces a fresh iv/key each call (no nonce reuse)', async () => { + const kid = await fingerprintOf(publicKeyPem); + const a = await encryptEnvelope('same', publicKeyPem, kid); + const b = await encryptEnvelope('same', publicKeyPem, kid); + expect(a).not.toBe(b); + }); + + it('isEncryptedEnvValue only matches the enc:v1 prefix', () => { + expect(isEncryptedEnvValue('plain')).toBe(false); + expect(isEncryptedEnvValue('enc:v2:x')).toBe(false); + expect(isEncryptedEnvValue(undefined)).toBe(false); + }); +}); diff --git a/src/lib/crypto/envSecret.ts b/src/lib/crypto/envSecret.ts new file mode 100644 index 000000000..77b4ca0ef --- /dev/null +++ b/src/lib/crypto/envSecret.ts @@ -0,0 +1,107 @@ +/** + * Client-side `enc:v1:` envelope encryption using the Web Crypto API, matching the backend contract + * in core/docs/env-secret-encryption.md. Hybrid: AES-256-GCM encrypts the value, RSA-OAEP(SHA-256) + * wraps the AES key. Secret values are encrypted in the browser and never leave it in plaintext. + * + * enc:v1: + */ + +export const ENV_ENCRYPTED_PREFIX = 'enc:v1:'; + +function pemToDer(pem: string): Uint8Array { + const b64 = pem + .replace(/-----BEGIN [^-]+-----/, '') + .replace(/-----END [^-]+-----/, '') + .replace(/\s+/g, ''); + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); } + return bytes; +} + +function toBase64(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) { s += String.fromCharCode(bytes[i]); } + return btoa(s); +} + +function toBase64Url(bytes: Uint8Array): string { + return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function toHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +// Web Crypto's BufferSource typing (TS 5.7+) rejects Uint8Array; hand it a plain +// ArrayBuffer copy of the bytes. +function ab(bytes: Uint8Array): ArrayBuffer { + // Avoid a copy when the view already spans its whole buffer (the common case here). + if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) { + return bytes.buffer as ArrayBuffer; + } + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +// Web Crypto's SubtleCrypto is only available in secure contexts (HTTPS or localhost). Fail with a +// clear message rather than a cryptic `undefined` access when the app is served over plain HTTP. +function requireSubtle(): SubtleCrypto { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Secret encryption requires a secure context (HTTPS or localhost).'); + } + return crypto.subtle; +} + +/** True if a value is an `enc:v1:` envelope rather than a plaintext value. */ +export function isEncryptedEnvValue(value: unknown): value is string { + return typeof value === 'string' && value.startsWith(ENV_ENCRYPTED_PREFIX); +} + +/** + * SHA-256 (hex) of the DER SPKI public key — the envelope `kid`. Matches the backend `fingerprintOf` + * so a value encrypted here targets the right key during rotation. + */ +export async function fingerprintOf(publicKeyPem: string): Promise { + const subtle = requireSubtle(); + const der = pemToDer(publicKeyPem); + return toHex(await subtle.digest('SHA-256', ab(der))); +} + +/** + * Encrypt a value into an `enc:v1:` envelope for the given public key. `kid` should be + * `fingerprintOf(publicKeyPem)` (the server returns it alongside the key). + */ +export async function encryptEnvelope(plaintext: string, publicKeyPem: string, kid: string): Promise { + const subtle = requireSubtle(); + const der = pemToDer(publicKeyPem); + const rsaKey = await subtle.importKey('spki', ab(der), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, [ + 'encrypt', + ]); + + const aesKeyBytes = crypto.getRandomValues(new Uint8Array(32)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const aesKey = await subtle.importKey('raw', ab(aesKeyBytes), { name: 'AES-GCM' }, false, ['encrypt']); + + const sealed = new Uint8Array( + await subtle.encrypt( + { name: 'AES-GCM', iv: ab(iv), tagLength: 128 }, + aesKey, + ab(new TextEncoder().encode(plaintext)), + ), + ); + // Web Crypto appends the 16-byte GCM tag to the ciphertext; the backend expects them separate. + const ct = sealed.slice(0, sealed.length - 16); + const tag = sealed.slice(sealed.length - 16); + const wrappedKey = new Uint8Array(await subtle.encrypt({ name: 'RSA-OAEP' }, rsaKey, ab(aesKeyBytes))); + + const envelope = { + kid, + k: toBase64(wrappedKey), + iv: toBase64(iv), + ct: toBase64(ct), + tag: toBase64(tag), + }; + return ENV_ENCRYPTED_PREFIX + toBase64Url(new TextEncoder().encode(JSON.stringify(envelope))); +} From e36d3b20890159309f719c8651e577ea2ee6ec0a Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 2 Jul 2026 12:44:06 -0400 Subject: [PATCH 2/4] feat(secrets): fetch the managed-cluster secrets key from central-manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit central-manager#409 makes CM the custodian for hosted clusters: it mints the per-cluster RSA keypair on first use (Studio's key fetch is what triggers the mint), stamps the fingerprint onto instances, and delivers the private key to host-managers — so the public key exists and is authoritative at CM even before any node has custody registered. Fabric-managed clusters now fetch the public key via POST /ClusterSecrets (get_secrets_public_key + clusterId, camelCase response); self-hosted and local instances keep the node operation (snake_case response). Both are normalized to one shape, and the kid-mismatch retry now also heals CM's documented first-mint race (the losing key's envelopes fail with a kid mismatch; re-encrypting against the stored winner fixes them). Values are unchanged: still encrypted in the browser and submitted to the cluster's own set_secret — they never pass through or live in CM. Co-Authored-By: Claude Fable 5 --- .../instance/config/secrets/index.tsx | 22 +++++-- .../api/instance/secrets/secrets.test.tsx | 31 ++++++++++ .../api/instance/secrets/secrets.ts | 62 ++++++++++++++----- 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/features/instance/config/secrets/index.tsx b/src/features/instance/config/secrets/index.tsx index 5bf5a7358..a82621ee3 100644 --- a/src/features/instance/config/secrets/index.tsx +++ b/src/features/instance/config/secrets/index.tsx @@ -1,6 +1,8 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery'; import { SecretGrantsEditor } from '@/features/instance/config/secrets/SecretGrantsEditor'; import { SecretRow, SecretsManager } from '@/features/instance/secrets/SecretsManager'; +import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; import { listSecretsQueryOptions, SecretMetadata, @@ -20,12 +22,20 @@ import { useCallback, useMemo } from 'react'; */ export function ConfigSecretsIndex() { const navigate = useNavigate(); - const { secretName }: { secretName?: string } = useParams({ strict: false }); + const { secretName, clusterId }: { secretName?: string; clusterId?: string } = useParams({ strict: false }); const instanceParams = useInstanceClientIdParams(); const { data, refetch, isFetching } = useQuery(listSecretsQueryOptions(instanceParams)); - // Without registered key custody (the Pro secretCustody component) the node has no secrets - // key: nothing can be encrypted, so the store is browsable read-only. - const publicKeyQuery = useQuery(secretsPublicKeyQueryOptions(instanceParams)); + + // Fabric-managed clusters get their public key from central-manager (the custodian — it mints + // the keypair on first use, central-manager#409); self-hosted/local nodes serve their own. + const { data: cluster } = useQuery(getClusterInfoQueryOptions(clusterId, false)); + const isSelfManaged = cluster === undefined || clusterIsSelfManaged(cluster); + const managedClusterId = !isSelfManaged ? clusterId : undefined; + const keySource = useMemo(() => ({ ...instanceParams, managedClusterId }), [instanceParams, managedClusterId]); + + // Without a secrets key (no custody registered, or CM unreachable) nothing can be encrypted, + // so the store is browsable read-only. + const publicKeyQuery = useQuery(secretsPublicKeyQueryOptions(keySource)); const secrets = data?.secrets; const rows = useMemo( @@ -46,9 +56,9 @@ export function ConfigSecretsIndex() { const { mutateAsync: deleteSecret } = useDeleteSecret(); const onSet = useCallback(async (name: string, value: string) => { - await setSecret({ ...instanceParams, name, value }); + await setSecret({ ...keySource, name, value }); await refetch(); - }, [setSecret, instanceParams, refetch]); + }, [setSecret, keySource, refetch]); const onDelete = useCallback(async (name: string) => { await deleteSecret({ ...instanceParams, name }); diff --git a/src/integrations/api/instance/secrets/secrets.test.tsx b/src/integrations/api/instance/secrets/secrets.test.tsx index 1d27cd2e1..b1ee25b02 100644 --- a/src/integrations/api/instance/secrets/secrets.test.tsx +++ b/src/integrations/api/instance/secrets/secrets.test.tsx @@ -17,6 +17,11 @@ import { secretsPublicKeyQueryOptions, useDeleteSecret, useSetSecret } from './s our cached copy) must refetch the key and re-encrypt exactly once. */ +// Managed clusters fetch the public key from central-manager's apiClient; keep the real module +// (and its env expectations) out of the test. +const { cmPost } = vi.hoisted(() => ({ cmPost: vi.fn() })); +vi.mock('@/config/apiClient', () => ({ apiClient: { post: cmPost } })); + // jsdom has no SubtleCrypto; Node's WebCrypto implements the same interface. beforeAll(() => { if (!globalThis.crypto?.subtle) { @@ -134,6 +139,32 @@ describe('useSetSecret', () => { ); }); + it('fetches the key from central-manager for managed clusters (central-manager#409)', async () => { + const { post, wrapper, params } = harness(); + const key = await generateKey(); + cmPost.mockResolvedValue({ + data: { publicKey: key.pem, fingerprint: key.fingerprint, scheme: 'enc:v1', algorithm: 'RSA-OAEP-256' }, + }); + post.mockImplementation((_url: string, body: { operation: string }) => + body.operation === 'set_secret' + ? Promise.resolve({ data: { name: 'API_KEY', kid: key.fingerprint, created: true } }) + : Promise.reject(new Error(`Unexpected operation ${body.operation}`)) + ); + + const { result } = renderHook(() => useSetSecret(), { wrapper }); + await result.current.mutateAsync({ ...params, managedClusterId: 'clu-123', name: 'API_KEY', value: 'v' }); + + expect(cmPost).toHaveBeenCalledWith('/ClusterSecrets', { + operation: 'get_secrets_public_key', + clusterId: 'clu-123', + }); + // The node op is never used for the key; the envelope targets CM's key; the write still + // goes to the cluster itself. + expect(post.mock.calls.filter(([, body]) => body.operation === 'get_secrets_public_key')).toHaveLength(0); + const setCall = post.mock.calls.find(([, body]) => body.operation === 'set_secret'); + expect(decodeEnvelope(setCall![1].envelope).kid).toBe(key.fingerprint); + }); + it('caches the public key between writes', async () => { const { post, wrapper, params, queryClient } = harness(); const key = await generateKey(); diff --git a/src/integrations/api/instance/secrets/secrets.ts b/src/integrations/api/instance/secrets/secrets.ts index e2cb942a8..b247e84bc 100644 --- a/src/integrations/api/instance/secrets/secrets.ts +++ b/src/integrations/api/instance/secrets/secrets.ts @@ -9,17 +9,23 @@ * normal system-table replication; at load time core materializes a secret only into components * listed in its `grants`. * - * Without a registered key custody provider (the Pro secretCustody component), the node has no - * public key — `get_secrets_public_key` fails with a clean error and nothing can be encrypted. - * `list_secrets` still works, so the store is browsable read-only in that state. + * The public key has two sources depending on who holds custody (normalized here to one shape): + * Fabric-managed clusters fetch it from central-manager (`POST /ClusterSecrets`, + * central-manager#409 — CM mints the per-cluster keypair on first use and delivers the private + * key to hosts), while self-hosted/local nodes serve their own file-tier key via the + * `get_secrets_public_key` operation. Without custody the node has no key — the fetch fails with + * a clean error and nothing can be encrypted; `list_secrets` still works, so the store is + * browsable read-only in that state. */ +import { apiClient } from '@/config/apiClient'; import { InstanceClientIdConfig } from '@/config/instanceClientConfig'; import { encryptEnvelope } from '@/lib/crypto/envSecret'; import { QueryClient, queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { AxiosInstance } from 'axios'; -/** `get_secrets_public_key` — the cluster key clients encrypt against. */ +/** The cluster key clients encrypt against, normalized across the two sources (see below). */ export interface SecretsPublicKey { - public_key: string; + publicKey: string; fingerprint: string; } @@ -52,15 +58,43 @@ function operationErrorMessage(error: unknown): string { return body?.error ?? body?.message ?? String(error); } -async function getSecretsPublicKey({ instanceClient }: InstanceClientIdConfig): Promise { - const { data } = await instanceClient.post('/', { operation: 'get_secrets_public_key' }); - return data; +async function getSecretsPublicKeyFromNode({ instanceClient }: InstanceClientIdConfig): Promise { + const { data } = await instanceClient.post<{ public_key: string; fingerprint: string }>('/', { + operation: 'get_secrets_public_key', + }); + return { publicKey: data.public_key, fingerprint: data.fingerprint }; +} + +// `apiClient` is a TypedAxios bound to the generated OpenAPI spec; `/ClusterSecrets` isn't in the +// spec yet, so use the plain Axios view. (Regenerate the SDK to type this once the endpoint ships.) +const cm = apiClient as unknown as AxiosInstance; + +async function getSecretsPublicKeyFromClusterManager(clusterId: string): Promise { + const { data } = await cm.post<{ publicKey: string; fingerprint: string }>('/ClusterSecrets', { + operation: 'get_secrets_public_key', + clusterId, + }); + return { publicKey: data.publicKey, fingerprint: data.fingerprint }; +} + +export interface SecretsPublicKeySource extends InstanceClientIdConfig { + /** + * For Fabric-managed clusters, the clusterId to fetch the key from central-manager + * (central-manager#409): CM is the custodian there — it mints the keypair on first use and + * delivers the private key to hosts, so the key exists (and is authoritative) even before any + * node has custody registered. Omit for self-hosted/local, where the node mints its own + * file-tier key and serves it via the `get_secrets_public_key` operation. + */ + managedClusterId?: string; } -export function secretsPublicKeyQueryOptions(params: InstanceClientIdConfig) { +export function secretsPublicKeyQueryOptions(params: SecretsPublicKeySource) { return queryOptions({ - queryKey: [params.entityId, 'get_secrets_public_key'] as const, - queryFn: () => getSecretsPublicKey(params), + queryKey: [params.entityId, 'get_secrets_public_key', params.managedClusterId ?? 'node'] as const, + queryFn: () => + params.managedClusterId + ? getSecretsPublicKeyFromClusterManager(params.managedClusterId) + : getSecretsPublicKeyFromNode(params), // The custody key can rotate (kid map), so refresh periodically instead of caching forever. // Failure is a state, not a blip: without custody the node simply has no key. staleTime: 5 * 60_000, @@ -80,7 +114,7 @@ export function listSecretsQueryOptions(params: InstanceClientIdConfig) { }); } -export interface SetSecretArgs extends InstanceClientIdConfig { +export interface SetSecretArgs extends SecretsPublicKeySource { name: string; value: string; } @@ -92,8 +126,8 @@ async function encryptAndSetSecret(queryClient: QueryClient, args: SetSecretArgs }> { // Encrypt against the cluster public key so the plaintext value never leaves the browser. The // envelope's sealed kid tells the cluster which key it was encrypted under. - const { public_key, fingerprint } = await queryClient.ensureQueryData(secretsPublicKeyQueryOptions(args)); - const envelope = await encryptEnvelope(args.value, public_key, fingerprint); + const { publicKey, fingerprint } = await queryClient.ensureQueryData(secretsPublicKeyQueryOptions(args)); + const envelope = await encryptEnvelope(args.value, publicKey, fingerprint); try { const { data } = await args.instanceClient.post('/', { operation: 'set_secret', From 41cf167c298c6e27d124d1540ab2c28a919a23c1 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 12:18:38 -0400 Subject: [PATCH 3/4] fix(secrets): admit 5.2.0 prerelease builds through the secrets version gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harper ships the hdb_secret store in the 5.2.0 line (harper#1554); dev builds carry prerelease tags like 5.2.0-alpha.1 / 5.2.0-beta.1. Floor the Secrets nav gate at 5.2.0-alpha.1 (the earliest 5.2 prerelease) so those builds pass it — a plain '5.2.0' floor would exclude every prerelease (SemVer ranks prereleases below the release), same convention as the existing 4.7.0-beta.7 / 4.7.0-alpha.1 gates. Also type the processEnv field that list_secrets rows carry in the merged harper#1554. Co-Authored-By: Claude Opus 4.8 --- src/features/instance/config/index.tsx | 16 +++++++++++++--- .../api/instance/secrets/secrets.ts | 2 ++ src/lib/string/wasAReleasedBeforeB.test.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/features/instance/config/index.tsx b/src/features/instance/config/index.tsx index b9699656b..d21f09550 100644 --- a/src/features/instance/config/index.tsx +++ b/src/features/instance/config/index.tsx @@ -8,7 +8,16 @@ 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 { GlobeIcon, HandshakeIcon, KeyIcon, LockIcon, PieChartIcon, RocketIcon, ShieldCheckIcon, UsersIcon } from 'lucide-react'; +import { + GlobeIcon, + HandshakeIcon, + KeyIcon, + LockIcon, + PieChartIcon, + RocketIcon, + ShieldCheckIcon, + UsersIcon, +} from 'lucide-react'; import { ReactNode, Suspense } from 'react'; const sharedClasses = 'flex items-center p-2 rounded-lg group'; @@ -25,8 +34,9 @@ export function ConfigIndex() { const certsAvailable = wasAReleasedBeforeB('4.6.0', version); const deploymentsAvailable = wasAReleasedBeforeB('5.1.0', version); // The hdb_secret store and its operations (list_secrets / set_secret / …) ship in Harper 5.2 - // (harper#1554's upgrade directive is tagged 5.2.0 — revisit if it ships in a different release). - const secretsSupported = wasAReleasedBeforeB('5.2.0', version); + // (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 { clusterId } = params; const canManage = useInstanceManagePermission(); diff --git a/src/integrations/api/instance/secrets/secrets.ts b/src/integrations/api/instance/secrets/secrets.ts index b247e84bc..3220e4ba5 100644 --- a/src/integrations/api/instance/secrets/secrets.ts +++ b/src/integrations/api/instance/secrets/secrets.ts @@ -36,6 +36,8 @@ export interface SecretMetadata { kid: string | null; /** Component names allowed to receive this secret at load time. */ grants: string[]; + /** Materialize into every component's process.env instead of per-component grants (mutually exclusive with grants). */ + processEnv?: boolean; metadata: Record; /** Stored without key-identity verification (kid-less envelope, or no custody at write time). */ unverified: boolean; diff --git a/src/lib/string/wasAReleasedBeforeB.test.ts b/src/lib/string/wasAReleasedBeforeB.test.ts index 1f5fb01a0..f854824ac 100644 --- a/src/lib/string/wasAReleasedBeforeB.test.ts +++ b/src/lib/string/wasAReleasedBeforeB.test.ts @@ -43,6 +43,23 @@ describe('wasAReleasedBeforeB (b >= a)', () => { expect(wasAReleasedBeforeB('3.4.5', '3.4.5')).toBe(true); expect(wasAReleasedBeforeB('3.4.5-beta.2', '3.4.5-beta.2')).toBe(true); }); + + // The Secrets nav gate (src/features/instance/config/index.tsx) floors at '5.2.0-alpha.1' rather + // than '5.2.0' so the harper#1554 alpha/beta dev builds pass it too. Guard that intent: a plain + // '5.2.0' floor would header-exclude every prerelease, hiding the page on the very builds it ships in. + it('5.2.0-alpha.1 secrets gate admits 5.2 prereleases/rc/final but not pre-5.2 builds', () => { + const gate = '5.2.0-alpha.1'; + expect(wasAReleasedBeforeB(gate, '5.1.15')).toBe(false); // current stage line — hidden + expect(wasAReleasedBeforeB(gate, '5.2.0-alpha.1')).toBe(true); // earliest 5.2 prerelease — shown + expect(wasAReleasedBeforeB(gate, '5.2.0-alpha.2')).toBe(true); + expect(wasAReleasedBeforeB(gate, '5.2.0-beta.1')).toBe(true); // the shipping beta — shown + expect(wasAReleasedBeforeB(gate, '5.2.0-rc.1')).toBe(true); + expect(wasAReleasedBeforeB(gate, '5.2.0')).toBe(true); // final release — shown + expect(wasAReleasedBeforeB(gate, '5.2.1')).toBe(true); + expect(wasAReleasedBeforeB(gate, '5.2.0-alpha.1+abcdef')).toBe(true); // build metadata ignored + // A plain '5.2.0' floor would wrongly hide the shipping prereleases — the bug this gate avoids: + expect(wasAReleasedBeforeB('5.2.0', '5.2.0-alpha.1')).toBe(false); + }); }); import { compareVersions } from './wasAReleasedBeforeB'; From b0434a1aa3df6cdae02ccdf0f3b54a07d138bafe Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 10 Jul 2026 17:42:14 -0400 Subject: [PATCH 4/4] feat(secrets): choose delivery tier + show how to read each secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster secrets (hdb_secret) can be delivered two mutually-exclusive ways (harper#1554): materialized onto process.env for every component (global), or scoped to granted apps and read through the `secrets` accessor. Expose that choice in the add/edit dialogs and, based on the pick, show an inline, copy-paste-correct code example of how to read the secret — `process.env.NAME` vs `const { NAME } = secrets` (bracket notation for names that aren't valid JS identifiers). - accessExample.ts: pure, name-aware example builder (+ unit tests). - SecretDeliveryPicker / SecretAccessExample / PendingGrantsInput: the tier radio, the copyable example, and an API-free grants collector for the add flow (the live grant_secret editor can't run pre-creation). - SecretModals / SecretsManager: opt-in `delivery` prop threads the tier through onSet as { processEnv, grants }; the .env panel is unchanged. - set_secret now sends processEnv/grants (omitted to preserve the stored tier on a plain value rotation). Scoped grants stay live (grant_secret/revoke_secret) in edit; env-var rows hide the grants editor since a global secret can't be scoped. Verified with a jsdom test driving the real dialog tree end-to-end. Co-Authored-By: Claude Opus 4.8 --- .../config/secrets/SecretGrantsEditor.tsx | 3 +- .../instance/config/secrets/index.tsx | 21 ++- .../instance/secrets/PendingGrantsInput.tsx | 95 +++++++++++++ .../instance/secrets/SecretAccessExample.tsx | 47 +++++++ .../instance/secrets/SecretDeliveryPicker.tsx | 67 +++++++++ .../instance/secrets/SecretModals.tsx | 92 +++++++++++-- .../secrets/SecretsManager.delivery.test.tsx | 129 ++++++++++++++++++ .../instance/secrets/SecretsManager.tsx | 32 ++++- .../instance/secrets/accessExample.test.ts | 55 ++++++++ .../instance/secrets/accessExample.ts | 61 +++++++++ .../api/instance/secrets/secrets.ts | 13 ++ 11 files changed, 593 insertions(+), 22 deletions(-) create mode 100644 src/features/instance/secrets/PendingGrantsInput.tsx create mode 100644 src/features/instance/secrets/SecretAccessExample.tsx create mode 100644 src/features/instance/secrets/SecretDeliveryPicker.tsx create mode 100644 src/features/instance/secrets/SecretsManager.delivery.test.tsx create mode 100644 src/features/instance/secrets/accessExample.test.ts create mode 100644 src/features/instance/secrets/accessExample.ts diff --git a/src/features/instance/config/secrets/SecretGrantsEditor.tsx b/src/features/instance/config/secrets/SecretGrantsEditor.tsx index fd39939b4..48f011273 100644 --- a/src/features/instance/config/secrets/SecretGrantsEditor.tsx +++ b/src/features/instance/config/secrets/SecretGrantsEditor.tsx @@ -69,7 +69,8 @@ export function SecretGrantsEditor({
    Granted applications

    - Only granted applications receive this secret in their environment. Changes apply immediately. + Only granted applications can read this secret through the secrets{' '} + accessor. Changes apply immediately.

    {grants.length > 0 && (
    diff --git a/src/features/instance/config/secrets/index.tsx b/src/features/instance/config/secrets/index.tsx index a82621ee3..227df6718 100644 --- a/src/features/instance/config/secrets/index.tsx +++ b/src/features/instance/config/secrets/index.tsx @@ -39,7 +39,12 @@ export function ConfigSecretsIndex() { const secrets = data?.secrets; const rows = useMemo( - () => (secrets ?? []).map((secret) => ({ name: secret.name, warning: warningFor(secret, data) })), + () => + (secrets ?? []).map((secret) => ({ + name: secret.name, + processEnv: secret.processEnv, + warning: warningFor(secret, data), + })), [secrets, data], ); const selectedName = useMemo(() => secrets?.find((s) => s.name === secretName)?.name, [secrets, secretName]); @@ -55,10 +60,13 @@ export function ConfigSecretsIndex() { const { mutateAsync: setSecret } = useSetSecret(); const { mutateAsync: deleteSecret } = useDeleteSecret(); - const onSet = useCallback(async (name: string, value: string) => { - await setSecret({ ...keySource, name, value }); - await refetch(); - }, [setSecret, keySource, refetch]); + const onSet = useCallback( + async (name: string, value: string, options?: { processEnv?: boolean; grants?: string[] }) => { + await setSecret({ ...keySource, name, value, processEnv: options?.processEnv, grants: options?.grants }); + await refetch(); + }, + [setSecret, keySource, refetch], + ); const onDelete = useCallback(async (name: string) => { await deleteSecret({ ...instanceParams, name }); @@ -84,8 +92,9 @@ export function ConfigSecretsIndex() { selectedName={selectedName} onSelectName={onSelectName} nameHeader="Secret" + delivery={true} addDescription="The value is encrypted in your browser against the cluster's secrets key — plaintext never reaches the API, the operation log, or disk. It can be replaced or deleted, but never read back." - editDescription="The current value can't be shown — it's stored encrypted. Enter a new value to replace it, adjust which applications receive it, or delete the secret." + editDescription="The current value can't be shown — it's stored encrypted. Enter a new value to replace it, adjust how applications read it, or delete the secret." valueDescription="Encrypted client-side before it leaves this page." onSet={onSet} onDelete={onDelete} diff --git a/src/features/instance/secrets/PendingGrantsInput.tsx b/src/features/instance/secrets/PendingGrantsInput.tsx new file mode 100644 index 000000000..da6d8bfdc --- /dev/null +++ b/src/features/instance/secrets/PendingGrantsInput.tsx @@ -0,0 +1,95 @@ +/** + * A local, API-free grants collector for the Add-secret flow: the secret doesn't exist yet, so + * grants can't be persisted with grant_secret (as the edit flow's live SecretGrantsEditor does) — + * they're gathered here and submitted in the initial set_secret call. Chip UI mirrors + * SecretGrantsEditor so the two read the same. + */ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { PlusIcon, XIcon } from 'lucide-react'; +import { KeyboardEvent, useCallback, useState } from 'react'; + +export function PendingGrantsInput({ + grants, + onChange, + disabled, +}: { + grants: string[]; + onChange: (next: string[]) => void; + disabled?: boolean; +}) { + const [component, setComponent] = useState(''); + + const add = useCallback(() => { + const target = component.trim(); + if (!target) { + return; + } + if (!grants.includes(target)) { + onChange([...grants, target]); + } + setComponent(''); + }, [component, grants, onChange]); + + const remove = useCallback((target: string) => { + onChange(grants.filter((granted) => granted !== target)); + }, [grants, onChange]); + + // This input lives inside the add form — Enter must add a grant, not submit the secret. + const onKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + add(); + } + }, [add]); + + return ( +
    + Granted applications +

    + Only these applications will be able to read the secret. You can grant more later — leave it empty to add grants + after creating the secret. +

    + {grants.length > 0 && ( +
    + {grants.map((granted) => ( + + {granted} + + + ))} +
    + )} +
    + setComponent(event.target.value)} + onKeyDown={onKeyDown} + disabled={disabled} + /> + +
    +
    + ); +} diff --git a/src/features/instance/secrets/SecretAccessExample.tsx b/src/features/instance/secrets/SecretAccessExample.tsx new file mode 100644 index 000000000..ab96ce1a7 --- /dev/null +++ b/src/features/instance/secrets/SecretAccessExample.tsx @@ -0,0 +1,47 @@ +/** + * A copyable, syntax-free code snippet showing how a component reads a secret of a given delivery + * tier. The example is name-aware (dot vs bracket access) — see {@link buildSecretAccessExample}. + */ +import { Button } from '@/components/ui/button'; +import { writeToClipboard } from '@/hooks/useCopyToClipboard'; +import { CheckIcon, CopyIcon } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { buildSecretAccessExample, SecretTier } from './accessExample'; + +export function SecretAccessExample({ name, tier }: { name: string; tier: SecretTier }) { + const code = buildSecretAccessExample(name, tier); + + const [copied, setCopied] = useState(false); + const resetTimer = useRef>(undefined); + useEffect(() => () => clearTimeout(resetTimer.current), []); + + const onCopy = useCallback(() => { + void writeToClipboard(code).then((ok) => { + if (!ok) { + return; + } + setCopied(true); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setCopied(false), 1200); + }); + }, [code]); + + return ( +
    +
    +				{code}
    +			
    + +
    + ); +} diff --git a/src/features/instance/secrets/SecretDeliveryPicker.tsx b/src/features/instance/secrets/SecretDeliveryPicker.tsx new file mode 100644 index 000000000..69d550e57 --- /dev/null +++ b/src/features/instance/secrets/SecretDeliveryPicker.tsx @@ -0,0 +1,67 @@ +/** + * The delivery-tier chooser shown in the add/edit secret dialogs: pick whether a secret is exposed + * as a global environment variable (`process.env.NAME`) or scoped to specific apps through the + * `secrets` accessor (`secrets.NAME`), and see the exact code to read it either way. The two tiers + * are mutually exclusive server-side (harper#1554), so the grants slot only renders when scoped. + */ +import { RadioGroup, RadioGroupItem } from '@/components/ui/radioGroup'; +import { ReactNode } from 'react'; +import { SecretTier } from './accessExample'; +import { SecretAccessExample } from './SecretAccessExample'; + +export function SecretDeliveryPicker({ + name, + tier, + onTierChange, + disabled, + grantsSlot, +}: { + /** The secret name, used to render a copy-paste-correct access example. */ + name: string; + tier: SecretTier; + onTierChange: (tier: SecretTier) => void; + disabled?: boolean; + /** Grants editor to show under the scoped option (pending-grants on add, live editor on edit). */ + grantsSlot?: ReactNode; +}) { + return ( +
    + How should applications read this secret? + onTierChange(value as SecretTier)} + disabled={disabled} + > + + + + + {tier === 'scoped' && grantsSlot} + +
    + + How to read it in your component + + +
    +
    + ); +} diff --git a/src/features/instance/secrets/SecretModals.tsx b/src/features/instance/secrets/SecretModals.tsx index c615f0dbc..b913c64f4 100644 --- a/src/features/instance/secrets/SecretModals.tsx +++ b/src/features/instance/secrets/SecretModals.tsx @@ -30,6 +30,17 @@ import { ReactNode, useCallback, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +import { SecretTier } from './accessExample'; +import { PendingGrantsInput } from './PendingGrantsInput'; +import { SecretDeliveryPicker } from './SecretDeliveryPicker'; + +/** What the add/edit dialogs report back on submit, beyond the key/value pair. */ +export interface SecretDeliveryOptions { + /** true → global (`process.env`); false → scoped; undefined → preserve the stored tier. */ + processEnv?: boolean; + /** Initial grants for a scoped secret (add flow only; edit manages grants live). */ + grants?: string[]; +} const secretKeySchema = z .string() @@ -44,15 +55,21 @@ export function AddSecretModal({ onSubmit, isModalOpen, setIsModalOpen, + delivery = false, + defaultTier = 'scoped', }: { description: ReactNode; valueDescription?: ReactNode; /** Keys that already exist — adding one of these is rejected (edit it instead). */ existingKeys?: string[]; /** Persist the new secret; reject to keep the dialog open and surface the error. */ - onSubmit: (data: { key: string; value: string }) => Promise; + onSubmit: (data: { key: string; value: string } & SecretDeliveryOptions) => Promise; isModalOpen: boolean; setIsModalOpen: (open: boolean) => void; + /** Show the delivery-tier chooser (process.env vs scoped) + grants + a live access example. */ + delivery?: boolean; + /** Tier pre-selected when the dialog opens (defaults to the safer scoped tier). */ + defaultTier?: SecretTier; }) { const schema = useMemo( () => @@ -71,24 +88,40 @@ export function AddSecretModal({ // Destructured unconditionally so react-hook-form tracks all three (see EditSecretModal). const { isDirty, isValid, isSubmitting: isPending } = form.formState; + // Delivery tier + initial grants live outside the value form (they aren't validated fields). + const [tier, setTier] = useState(defaultTier); + const [grants, setGrants] = useState([]); + const liveName = form.watch('key'); + + const resetDelivery = useCallback(() => { + setTier(defaultTier); + setGrants([]); + }, [defaultTier]); + const onSubmitClick = useCallback( async (formData: z.infer) => { + // Scoped is the default tier server-side; only send delivery fields when the chooser is on. + const deliveryFields: SecretDeliveryOptions = delivery + ? { processEnv: tier === 'processEnv', grants: tier === 'scoped' ? grants : undefined } + : {}; try { - await onSubmit(formData); + await onSubmit({ ...formData, ...deliveryFields }); form.reset(); + resetDelivery(); toast.success(`Secret "${formData.key}" saved.`); setIsModalOpen(false); } catch (error) { toast.error(String(error)); } }, - [onSubmit, form, setIsModalOpen], + [onSubmit, form, setIsModalOpen, delivery, tier, grants, resetDelivery], ); const onClickCancel = useCallback(() => { form.reset(); + resetDelivery(); setIsModalOpen(false); - }, [form, setIsModalOpen]); + }, [form, setIsModalOpen, resetDelivery]); return ( @@ -134,6 +167,16 @@ export function AddSecretModal({ )} /> + {delivery && ( + } + /> + )} +