diff --git a/src/features/instance/config/index.tsx b/src/features/instance/config/index.tsx index 0b8d4966e..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, 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 +33,10 @@ 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). 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(); @@ -102,6 +115,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 can read this secret through the secrets{' '} + accessor. 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..227df6718 --- /dev/null +++ b/src/features/instance/config/secrets/index.tsx @@ -0,0 +1,132 @@ +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, + 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, clusterId }: { secretName?: string; clusterId?: string } = useParams({ strict: false }); + const instanceParams = useInstanceClientIdParams(); + const { data, refetch, isFetching } = useQuery(listSecretsQueryOptions(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( + () => + (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]); + + 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, 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 }); + 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/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 && ( + } + /> + )} +