-
Notifications
You must be signed in to change notification settings - Fork 4
feat(secrets): cluster secrets page on the hdb_secret store #1402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
7cd1581
feat(secrets): cluster secrets page on the hdb_secret store
dawsontoth e36d3b2
feat(secrets): fetch the managed-cluster secrets key from central-man…
dawsontoth 41cf167
fix(secrets): admit 5.2.0 prerelease builds through the secrets versi…
dawsontoth b0434a1
feat(secrets): choose delivery tier + show how to read each secret
dawsontoth 5ee0f1d
fix(secrets): address review — no banner flash, no doomed grant on un…
dawsontoth c2fac6a
fix(secrets): don't route a managed cluster to the node key on failed…
dawsontoth File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
src/features/instance/config/secrets/SecretGrantsEditor.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| /** | ||
| * Grants editor for one secret, shown inside the edit dialog. A secret is only materialized into | ||
| * the environment of components listed in its grants, so this is where a stored secret actually | ||
| * gets scoped to applications. Grant/revoke apply immediately (they are their own operations, not | ||
| * part of the value form). | ||
| */ | ||
| import { Badge } from '@/components/ui/badge'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import { Input } from '@/components/ui/input'; | ||
| import { useInstanceClientIdParams } from '@/config/useInstanceClient'; | ||
| import { useGrantSecret, useRevokeSecret } from '@/integrations/api/instance/secrets/secrets'; | ||
| import { PlusIcon, XIcon } from 'lucide-react'; | ||
| import { KeyboardEvent, useCallback, useState } from 'react'; | ||
| import { toast } from 'sonner'; | ||
|
|
||
| export function SecretGrantsEditor({ | ||
| name, | ||
| initialGrants, | ||
| onChanged, | ||
| }: { | ||
| name: string; | ||
| initialGrants: string[]; | ||
| /** Called after a successful grant/revoke so the list view can refresh its metadata. */ | ||
| onChanged?: () => 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<HTMLInputElement>) => { | ||
| if (event.key === 'Enter') { | ||
| event.preventDefault(); | ||
| void onGrantClick(); | ||
| } | ||
| }, [onGrantClick]); | ||
|
|
||
| return ( | ||
| <div className="grid gap-2"> | ||
| <span className="text-sm font-medium">Granted applications</span> | ||
| <p className="text-sm text-muted-foreground"> | ||
| Only granted applications can read this secret through the <code className="font-mono">secrets</code>{' '} | ||
| accessor. Changes apply immediately. | ||
| </p> | ||
| {grants.length > 0 && ( | ||
| <div className="flex flex-wrap gap-1"> | ||
| {grants.map((granted) => ( | ||
| <Badge key={granted} variant="secondary"> | ||
| {granted} | ||
| <button | ||
| type="button" | ||
| onClick={() => void onRevokeClick(granted)} | ||
| disabled={busy} | ||
| title={`Revoke ${granted}`} | ||
| className="cursor-pointer disabled:cursor-default" | ||
| > | ||
| <XIcon /> | ||
| <span className="sr-only">Revoke {granted}</span> | ||
| </button> | ||
| </Badge> | ||
| ))} | ||
| </div> | ||
| )} | ||
| <div className="flex gap-2"> | ||
| <Input | ||
| type="text" | ||
| autoComplete="off" | ||
| autoCapitalize="off" | ||
| placeholder="application name" | ||
| value={component} | ||
| onChange={(event) => setComponent(event.target.value)} | ||
| onKeyDown={onComponentKeyDown} | ||
| disabled={busy} | ||
| /> | ||
| <Button | ||
| type="button" | ||
| variant="positiveOutline" | ||
| onClick={() => void onGrantClick()} | ||
| disabled={busy || !component.trim()} | ||
| > | ||
| <PlusIcon /> Grant | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| 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 clusterQuery = useQuery(getClusterInfoQueryOptions(clusterId, false)); | ||
| const cluster = clusterQuery.data; | ||
| const isSelfManaged = cluster === undefined || clusterIsSelfManaged(cluster); | ||
| const managedClusterId = !isSelfManaged ? clusterId : undefined; | ||
| const keySource = useMemo(() => ({ ...instanceParams, managedClusterId }), [instanceParams, managedClusterId]); | ||
|
|
||
| // The key source (node vs central-manager) depends on the cluster tier, so it isn't known until | ||
| // the cluster lookup SUCCEEDS. `cluster === undefined` covers both the load window and a failed | ||
| // fetch (getClusterInfoQuery has retry:false, so `data` stays undefined on error) — treating | ||
| // either as self-managed would route a managed cluster to the node key: a transient banner flash | ||
| // while loading, and a non-self-healing mis-route on error (set_secret would then encrypt against | ||
| // the wrong key and fail a kid mismatch the retry can't heal). So gate the fetch on isSuccess, | ||
| // and only skip the gate when there's no clusterId (local Studio — the node is always right). | ||
| const clusterTierKnown = !clusterId || clusterQuery.isSuccess; | ||
| // Without a secrets key (no custody registered, or CM unreachable) nothing can be encrypted, so | ||
| // the store stays browsable read-only. | ||
| const publicKeyQuery = useQuery({ | ||
| ...secretsPublicKeyQueryOptions(keySource), | ||
| enabled: clusterTierKnown, | ||
| }); | ||
| // A failed cluster lookup is a distinct degraded state from "no secrets key" — surface it as such | ||
| // instead of leaving the page silently read-only (or, worse, guessing the wrong custody). | ||
| const clusterInfoUnavailable = !!clusterId && clusterQuery.isError; | ||
|
|
||
| const secrets = data?.secrets; | ||
| const rows = useMemo<SecretRow[]>( | ||
| () => | ||
| (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, reset: resetSetSecret } = useSetSecret(); | ||
| const { mutateAsync: deleteSecret } = useDeleteSecret(); | ||
|
|
||
| const onSet = useCallback( | ||
| async (name: string, value: string, options?: { processEnv?: boolean; grants?: string[] }) => { | ||
| try { | ||
| await setSecret({ ...keySource, name, value, processEnv: options?.processEnv, grants: options?.grants }); | ||
| } finally { | ||
| // Drop the plaintext `value` that lingers in the mutation's `variables` after the call. | ||
| resetSetSecret(); | ||
| } | ||
| await refetch(); | ||
| }, | ||
| [setSecret, resetSetSecret, keySource, refetch], | ||
| ); | ||
|
|
||
| const onDelete = useCallback(async (name: string) => { | ||
| await deleteSecret({ ...instanceParams, name }); | ||
| await refetch(); | ||
| }, [deleteSecret, instanceParams, refetch]); | ||
|
|
||
| return ( | ||
| <> | ||
| {clusterInfoUnavailable | ||
| ? ( | ||
| <p className="flex items-start gap-2 text-sm text-muted-foreground border border-amber-500/50 rounded-md p-3 mb-4"> | ||
| <TriangleAlertIcon className="size-4 text-amber-500 shrink-0 mt-0.5" /> | ||
| <span> | ||
| Secrets are read-only right now: this cluster's info couldn't be loaded, so its secret custody is unknown. | ||
| Refresh once cluster info is available. | ||
| </span> | ||
| </p> | ||
| ) | ||
| : publicKeyQuery.isError && ( | ||
| <p className="flex items-start gap-2 text-sm text-muted-foreground border border-amber-500/50 rounded-md p-3 mb-4"> | ||
| <TriangleAlertIcon className="size-4 text-amber-500 shrink-0 mt-0.5" /> | ||
| <span> | ||
| 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. | ||
| </span> | ||
| </p> | ||
| )} | ||
| <SecretsManager | ||
| rows={rows} | ||
| isFetching={isFetching} | ||
| onRefresh={refetch} | ||
| canManage={publicKeyQuery.isSuccess} | ||
| 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 how applications read it, or delete the secret." | ||
| valueDescription="Encrypted client-side before it leaves this page." | ||
| onSet={onSet} | ||
| onDelete={onDelete} | ||
| renderEditExtras={(name) => { | ||
| const secret = secrets?.find((s) => s.name === name); | ||
| return ( | ||
| secret && ( | ||
| <SecretGrantsEditor | ||
| key={secret.name} | ||
| name={secret.name} | ||
| initialGrants={secret.grants} | ||
| onChanged={() => 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; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.