Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/features/instance/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -102,6 +115,24 @@ export function ConfigIndex() {
</Link>
</li>
)}
{
/* 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). Manage-gated
like every entry here (it lives under the `canManage` block above); the secret operations
are additionally super_user-only server-side, so a deep-link by a lesser role lists nothing. */
}
{secretsSupported && (
<li>
<Link
to={buildAbsoluteLinkToPage(params, 'config/secrets')}
className={sharedClasses}
inactiveProps={inactiveProps}
activeProps={activeProps}
>
<LockIcon className="hidden md:inline-block" /> <span className="ms-3">Secrets</span>
</Link>
</li>
)}
</>
);

Expand Down
17 changes: 17 additions & 0 deletions src/features/instance/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,6 +86,19 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType<typeof cre
component: ConfigDeploymentsIndex,
});

const instanceConfigSecretsRoute = createRoute({
getParentRoute: () => 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',
Expand Down Expand Up @@ -115,6 +129,9 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType<typeof cre
instanceConfigSSHKeysRoute,
instanceConfigSSHKeyRoute,

instanceConfigSecretsRoute,
instanceConfigSecretRoute,

instanceConfigCertificatesRoute,
instanceConfigCertificateRoute,
]);
Expand Down
116 changes: 116 additions & 0 deletions src/features/instance/config/secrets/SecretGrantsEditor.tsx
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>
);
}
162 changes: 162 additions & 0 deletions src/features/instance/config/secrets/index.tsx
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);
Comment thread
dawsontoth marked this conversation as resolved.
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;
}
Loading