Skip to content
Open
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
31 changes: 30 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,22 @@ 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). */
}
{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>
);
}
132 changes: 132 additions & 0 deletions src/features/instance/config/secrets/index.tsx
Original file line number Diff line number Diff line change
@@ -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<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 } = 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 && (
<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