Skip to content

feat(secrets): cluster secrets page on the hdb_secret store#1402

Merged
dawsontoth merged 6 commits into
stagefrom
feat/secrets-management-ui
Jul 13, 2026
Merged

feat(secrets): cluster secrets page on the hdb_secret store#1402
dawsontoth merged 6 commits into
stagefrom
feat/secrets-management-ui

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Built on the shared secrets components from #1409 (merged). The backends it consumes have landed — core's hdb_secret store (harper#1554), the Pro key-custody layer (harper-pro#512, tracking issue harper-pro#166), and central-manager's custody keypair endpoints (central-manager#409); only host-manager#130 (key delivery into Fabric containers) is still in flight, which affects runtime decrypt on managed clusters, not this UI. Version-gated to Harper ≥ 5.2 (5.2.0-alpha.1), so it's invisible until a cluster opts into 5.2 — safe to merge when ready.

What

Config → Secrets manages the replicated system.hdb_secret store: named, envelope-encrypted rows on the cluster itself, edited through the per-instance operations API and scoped to applications via grants. Built on the shared SecretsManager / SecretModals components from #1409.

  • integrations/api/instance/secrets/secrets.ts — hooks for list_secrets / set_secret / delete_secret / grant_secret / revoke_secret, plus public-key resolution:
    • Managed clusters fetch the public key from central-manager (POST /ClusterSecrets, per central-manager#409) — CM is the custodian there, and Studio's key fetch is what triggers the first-use mint. Requires cluster-update rights on first use (the mint stamps fingerprints onto instances). The fetch waits for the cluster lookup to succeed before choosing node-vs-CM, so a managed cluster is never mis-routed to the node key (even on a failed lookup).
    • Self-hosted / local instances use the node's get_secrets_public_key operation (file-tier custody mints its own key).
    • Both shapes (camelCase vs snake_case) normalize to one interface.
  • Client-side encryption (lib/crypto/envSecret.ts, the enc:v1 codec harper#1554 ported into core): values are encrypted in the browser and submitted as envelopes to the cluster's own set_secret — plaintext never reaches the operations API, the operation log, disk, or CM, and can never be read back.
  • Delivery tier + access examples: each secret is either scoped (read via the secrets accessor — const { NAME } = secrets — by the apps you grant) or a global environment variable (process.env.NAME, every component and its child processes). The add/edit dialogs let you pick the tier and show a copy-paste-correct, name-aware code example for reading it; the two tiers are mutually exclusive server-side (a global secret can't be scoped).
  • Rotation/race-aware: the public key is cached for minutes (not forever); a kid-mismatch rejection drops the cached key, re-encrypts against the freshly served key, and retries once. This is also the documented heal for CM's transient first-mint race (two CM workers minting concurrently — the loser's envelopes kid-mismatch, re-encrypting fixes them).
  • Grants editor in the edit dialog: for scoped secrets, grant/revoke apply immediately; env-var secrets hide the grants editor (they're global), and flipping a saved env-var secret to scoped prompts a save first so a doomed grant_secret never fires. Per-row warning icons flag rows whose kid no longer matches the cluster's custody key (or stored unverified) — they may fail to decrypt until re-saved.
  • Custody-aware degradation: without a secrets key (no custody registered, CM unreachable, or pre-[Enhancement] Added syntax highlighting for .mjs files #409 CM) the list stays browsable, a banner explains why, and add/edit is disabled. A failed cluster lookup gets its own distinct read-only banner rather than the misleading "no key" one.
  • Gating: nav entry is version-gated (>= 5.2.0-alpha.1, so 5.2 beta/prerelease builds pass too) and manage-gated like every other Config entry; the secret operations are super_user-only server-side. The store ships in core, so it isn't restricted to Fabric-managed clusters.

Verification

  • tsc, oxlint, dprint clean; full suite green (1289 tests). Secrets-hook tests run real WebCrypto RSA keys, asserting plaintext never appears in a request body, the CM-vs-node key routing, and the rotation retry re-encrypting under the newly served key; jsdom tests drive the delivery-tier dialog end-to-end (the process.env vs secrets example swap, the submitted { processEnv, grants }, and the grant-scoping guard on an unsaved tier switch).
  • Click-tested live on stage (Harper 5.1.14, CM without [Enhancement] Added syntax highlighting for .mjs files #409): the version gate correctly hides the nav entry, and direct navigation shows the degraded state (banner, read-only, empty list) with the key fetch correctly routed to CM's /ClusterSecrets for the managed cluster.
  • The full write path (set/grant/delete) needs a Harper build with fix(ssh-keys): validate Add SSH Key form onChange #1554 and a CM with [Enhancement] Added syntax highlighting for .mjs files #409 — covered by the hook tests until then.

Notes / open items

  • The version gate floors at 5.2.0-alpha.1 (matching harper#1554's upgrade directive, tagged 5.2.0) so alpha/beta prerelease builds pass too; retag if it ships in a different release.
  • /ClusterSecrets isn't in central-manager's generated OpenAPI spec (the generator only emits method-level @path JSDoc, and these resources carry class-level docs), so regenerating the SDK won't type it — the plain-Axios view is the standing approach, not a stopgap.
  • The rotation retry keys off harper#1554's error phrase (does not match), centralized in one documented constant as a cross-repo contract; swap for a stable error code once the backend exposes one.
  • Grant names are free text (grants may precede a deploy, matching the backend's stance); a picker fed by get_components would be a nice follow-up.
  • History: the first cut posted to the central-manager ClusterSecrets store from central-manager#405 (control-plane values + host-manager value injection). That model was superseded by the two-key custody plan (harper-pro#166 / central-manager#409: CM keeps the key, the cluster keeps the ciphertext), and this page was retargeted accordingly.

🤖 Generated with Claude Code


Related PRs — secrets management

One enc:v1: client contract everywhere (public key + envelope; values encrypted before they leave the client):

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@dawsontoth

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces client-side envelope encryption (enc:v1) using the Web Crypto API to securely manage cluster-level deployment secrets, including UI components for listing, adding, and editing secrets. Feedback focuses on utilizing the query cache for the public key, adding a deletion confirmation prompt, handling non-secure contexts where the Web Crypto API is unavailable, ensuring type safety when closing the edit modal, and optimizing ArrayBuffer slicing to avoid unnecessary memory allocations.

Comment thread src/integrations/api/instance/secrets/clusterSecrets.ts Outdated
Comment thread src/features/instance/config/secrets/modals/EditSecretModal.tsx Outdated
Comment thread src/lib/crypto/envSecret.ts
Comment thread src/lib/crypto/envSecret.ts
Comment thread src/integrations/api/instance/secrets/clusterSecrets.ts Outdated
Comment thread src/features/instance/config/secrets/index.tsx Outdated
Comment thread src/lib/crypto/envSecret.ts
@dawsontoth
dawsontoth force-pushed the feat/secrets-management-ui branch from f3a8577 to a2827d7 Compare July 2, 2026 14:32
@dawsontoth dawsontoth changed the title feat(secrets): cluster-level deployment secrets UI feat(secrets): cluster secrets UI + version-adaptive application .env editor Jul 2, 2026
@dawsontoth
dawsontoth force-pushed the feat/secrets-management-ui branch from f7aafe5 to d47f200 Compare July 2, 2026 16:16
@dawsontoth
dawsontoth changed the base branch from stage to feat/env-file-editor July 2, 2026 16:40
@dawsontoth
dawsontoth force-pushed the feat/secrets-management-ui branch from 448fef2 to 775ba23 Compare July 2, 2026 16:41
@dawsontoth dawsontoth changed the title feat(secrets): cluster secrets UI + version-adaptive application .env editor feat(secrets): cluster secrets page on the hdb_secret store Jul 2, 2026
Base automatically changed from feat/env-file-editor to stage July 6, 2026 12:37
dawsontoth and others added 2 commits July 10, 2026 12:08
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 <noreply@anthropic.com>
…ager

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 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the feat/secrets-management-ui branch from 73d8841 to c481285 Compare July 10, 2026 18:12
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 49% 4843 / 9882
🔵 Statements 49.37% 5158 / 10447
🔵 Functions 40.81% 1158 / 2837
🔵 Branches 41.88% 3186 / 7606
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/instance/config/index.tsx 18.75% 0% 0% 18.75% 32-205
src/features/instance/config/routes.ts 67.39% 100% 51.61% 67.39% 18, 25, 33, 39, 46, 52, 59, 66, 72, 79, 85, 92, 98, 105, 111
src/features/instance/config/secrets/SecretGrantsEditor.tsx 0% 0% 0% 0% 26-108
src/features/instance/config/secrets/index.tsx 0% 0% 0% 0% 24-161
src/features/instance/secrets/PendingGrantsInput.tsx 63.15% 60% 50% 66.66% 27, 36, 41-43, 61
src/features/instance/secrets/SecretAccessExample.tsx 53.33% 25% 40% 50% 19-25
src/features/instance/secrets/SecretDeliveryPicker.tsx 100% 100% 100% 100%
src/features/instance/secrets/SecretModals.tsx 89.83% 94% 92.3% 89.83% 114, 121-123, 270, 289
src/features/instance/secrets/SecretsManager.tsx 96.55% 91.42% 94.11% 100% 105
src/features/instance/secrets/accessExample.ts 100% 100% 100% 100%
src/integrations/api/instance/secrets/secrets.ts 70.73% 73.33% 57.89% 74.35% 120-127, 217-230
src/lib/crypto/envSecret.ts 94.73% 80% 100% 94.11% 45, 52
Generated in workflow #1467 for commit c2fac6a by the Vitest Coverage Report Action

…on gate

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 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the feat/secrets-management-ui branch from c481285 to 41cf167 Compare July 10, 2026 21:09
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 <noreply@anthropic.com>
@dawsontoth
dawsontoth marked this pull request as ready for review July 10, 2026 22:10
@dawsontoth
dawsontoth requested a review from a team as a code owner July 10, 2026 22:10
@dawsontoth

Copy link
Copy Markdown
Contributor Author

This can be ready-for-review now, since the surface we expose in Harper shouldn't change meaningfully, and no one will see this until they're rocking 5.2.0 with us. We can make more little adjustments as we see fit, too. 🥳

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with a focus on the client-side secret handling, and the security fundamentals hold up well — nicely done, and unusually well-tested for a crypto path. No blockers.

Security core (the part that matters):

  • Plaintext never leaves the browser: values are AES-256-GCM encrypted with an RSA-OAEP-wrapped key before submission, set_secret carries envelope and never value, and that's asserted directly (setBody.value undefined, JSON.stringify(setBody) doesn't contain the plaintext). Nothing plaintext in the DOM, URL (route param is the name only), or error messages (kid mismatch surfaces a fingerprint, not the value).
  • Crypto correctness is verified against the real backend shape — fresh 32-byte key + 12-byte IV per call (nonce-freshness asserted), correct GCM tag split, fingerprintOf = SHA-256(DER SPKI), round-tripped through a Node privateDecrypt/GCM stand-in across unicode/multiline/PEM/empty inputs. requireSubtle() fails closed outside a secure context. Solid.
  • Rotation is fail-safe: a kid mismatch drops the cached key and re-encrypts exactly once (bounded by isRetry), second mismatch surfaces.

Two UX items I'd suggest tightening (recoverable, no data loss, but confusing traps):

Significant — managed-cluster banner-flash + wrong-target key fetch (config/secrets/index.tsx:32). While the cluster query is pending, cluster === undefinedisSelfManaged = truekeySource targets the node, so publicKeyQuery fires a node get_secrets_public_key that fails on a CM-custody cluster and flashes the amber read-only banner until the cluster query resolves. Gating the public-key query on the cluster query settling (enabled: clusterId === undefined || !clusterPending) would avoid it.

Significant — live grants editor renders on an unsaved tier switch (SecretModals.tsx:342). Editing a processEnv secret and flipping the radio to "Scoped" (not yet saved) still renders the live SecretGrantsEditor; clicking Grant fires grant_secret immediately against a secret still processEnv server-side → rejected. Gating the live editor behind tierChanged (e.g. "Save first to manage grants") would close the trap.

Minor suggestions: pending grant text typed but not "Add"-ed is silently dropped on submit (PendingGrantsInput.tsx:24onBlur={add} or block submit while non-empty); the rotation auto-heal couples to the backend's error wording via message.includes('does not match') (secrets.ts:158) — a structured error code from harper#1554 would be more robust; and a mutation.reset() after success would shorten the window the plaintext value lingers in useMutation variables (in-memory only, low risk, just hygiene).

Two Gemini bot findings (warning icon has no title; delete double-fires) are rebutted by the actual head code — the icon has <title>/aria-label and the confirm button is disabled={busy} where busy includes isDeleting.

A couple of questions for you:

  • Should the Secrets nav entry gate on useInstanceManagePermission() like the other Config entries, or is browsable-to-all (metadata-only; list_secrets returns no envelopes/values) the intended design? If intended, a one-line comment at the gate would keep it from being "fixed" later by mistake — a read-only user reaching the route will see secret names.
  • The write path can't be integration-tested until its backends land (harper#1554, harper-pro#512, central-manager#409), and the PR self-describes as a draft stacked on #1409 but isn't marked draft here — what's the intended merge posture?

Approve-worthy on the security core; the UX items are your call on timing relative to the backends.


🤖 Reviewed by KrAIs (Claude Opus 4.8) on Kris's behalf.

…saved tier switch

Review feedback from Kris on #1402:

- Managed-cluster banner flash: while the cluster lookup was pending,
  `cluster === undefined` read as self-managed, so the public-key query
  targeted the node and failed on a CM-custody cluster, flashing the
  read-only banner. Gate the key fetch on the cluster query settling
  (or no clusterId, i.e. local Studio, where the node is correct).

- Live grants editor on an unsaved tier switch: flipping a processEnv
  secret to Scoped (not yet saved) rendered the live SecretGrantsEditor,
  whose Grant fires grant_secret against a still-processEnv secret →
  rejected. Gate it behind an unsaved change: show a "save as scoped
  first" hint until the scoped save lands.

Minor items:
- PendingGrantsInput commits a typed-but-not-Added grant on blur, so
  submitting the Add form no longer silently drops it.
- Clear the set_secret mutation after each call so the plaintext value
  doesn't linger in useMutation variables.
- Comment the Secrets nav gate: manage-gated like its siblings, and the
  secret ops are super_user-only server-side.

Covers fix #2 with two jsdom tests (grants editor hidden on the unsaved
switch, shown when the stored tier is already scoped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Thanks Kris — addressed the two significant items and most minors in 5ee0f1df:

  • Managed-cluster banner flash + wrong-target key fetch (config/secrets/index.tsx) — fixed. The public-key query is now gated on the cluster lookup settling (enabled: !clusterId || !clusterQuery.isPending), so a CM-custody cluster no longer fires a node get_secrets_public_key (and flashes the read-only banner) during the pending window. When there's no clusterId (local Studio) the node is targeted immediately, as before.
  • Live grants editor on an unsaved tier switch (SecretModals.tsx) — fixed. Flipping a processEnv secret to Scoped now shows a "Save this as a scoped secret first, then you can grant applications" hint instead of the live SecretGrantsEditor, so no grant_secret fires against a still-processEnv secret. The live editor renders only when the stored tier is already scoped. Two jsdom tests cover both cases.
  • Pending grant text dropped on submit (PendingGrantsInput.tsx) — fixed with onBlur={add}; submitting the Add form (which blurs the field first) now commits the typed name. add() no-ops on empty/duplicate, so it's safe against the Add-button double-fire.
  • Plaintext lingering in useMutation variables — the set_secret mutation is now reset() after each call.
  • Nav permission gate (Q1) — it already gates on useInstanceManagePermission(): the Secrets entry lives inside the shared canManage block with every other Config entry, and the secret ops are super_user-only server-side, so a deep-link by a lesser role lists nothing. Added a one-line comment at the gate to record that intent (browsable metadata to managers is the design, per your read).

Not changed:

  • Rotation error-code coupling (secrets.tsmessage.includes('does not match')) — agreed a structured code is more robust, but that's gated on harper#1554 emitting one; it currently only returns the message string. Left as-is with the retry bounded by isRetry; will switch to a code field when the backend exposes one.

Left for Dawson:

  • Merge posture (Q2) — the draft/stacked-on-#1409 question and merge sequencing relative to the backends (harper#1554, harper-pro#512, central-manager#409) are his call.

🤖 Addressed by Claude Code

Comment thread src/features/instance/config/secrets/index.tsx
Comment thread src/integrations/api/instance/secrets/secrets.ts Outdated
… cluster lookup

Follow-up to Barber AI review on #1402:

- Key-source routing conflated "cluster info loading OR failed" with
  self-managed. getClusterInfoQuery has retry:false, so on error `data`
  stays undefined permanently — the prior !isPending gate re-enabled the
  key fetch but still routed a managed cluster to the node, a
  non-self-healing mis-route (set_secret would encrypt against the wrong
  key and fail a kid mismatch the retry can't heal). Gate the fetch on
  the cluster lookup actually succeeding (clusterTierKnown = !clusterId
  || clusterQuery.isSuccess), and surface a failed lookup as its own
  read-only banner instead of a misleading "no secrets key" one.

- Centralize the kid-mismatch rotation sentinel (`does not match`) in one
  documented KID_MISMATCH_ERROR_PHRASE / isKidMismatchError, flagged as a
  cross-repo contract with harper#1554 to swap for a stable error code
  once one is exposed. Covered by the existing rotation-retry test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

IMO this can merge when we're ready, we can always make adjustments if we have a big shift in the API surface before 5.2.0 lands. It's properly gated, so no one will see this until we're ready to show it to them (or they opt in to seeing it, in which case, that's also fantastic for us).

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at c2fac6a — both significant items from my last pass are fixed correctly: the managed-cluster banner-flash / wrong-target key fetch, and the live grants editor firing against a still-processEnv secret on an unsaved tier switch. The client-side crypto core (plaintext never leaves the browser, envelope correctness, bounded rotation retry) re-verified intact, and the write-path backends have since merged so the earlier can't-integration-test caveat is resolved. Clean security work — approving.

— KrAIs (Claude Opus 4.8), reviewing on Kris's behalf

@dawsontoth
dawsontoth added this pull request to the merge queue Jul 13, 2026
Merged via the queue into stage with commit ad4e8c6 Jul 13, 2026
3 checks passed
@dawsontoth
dawsontoth deleted the feat/secrets-management-ui branch July 13, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants