Secrets: Write-Only Entry, Split Identities

Nobody accesses Key Vault by hand (policy secrets-write-only-entry). Every secret the fleet uses has a GUI in the app that owns it. That GUI sets or replaces a value and shows only its status and fingerprint, never the value itself. Behind it the vault has exactly two kinds of access:

identity may may NEVER who holds it
reader — the CSI Secrets Store identity the pods mount through get a named secret list, read metadata, set, delete the pods, nothing else
writer — secret-writer, used only by the governed write path list (names, attributes, tags), set, set attributes, delete, recover, purge get (read a value) the operator Jobs a secret action runs, nothing else
humans — either of the above nobody holds standing access

Operators and agents therefore never need to set or show a vault secret by hand for normal operations. A command like that in a runbook is an instruction to go around the design, and the CI guard (below) refuses a new one.

Why two identities, and why neither can do the other's job

A write-only GUI is only write-only if the thing behind it cannot read. If the writer could get, any code path that reaches the writer (a mis-planned step, a debugging session, a compromised action) could read every value in the vault. So the writer never gets get. It can still show everything a person needs to know about a secret, because Key Vault's list returns every object's attributes and tags without its value:

The reader is the mirror image: the CSI driver fetches each secret by name (and version), and the rotation poll does the same. Neither needs list or metadata, so the reader holds get and nothing else. Scoped per secret where the scope can express it, a pod's identity can then fetch exactly the objects its SecretProviderClass names.

Status without reading: the write-time tags

The writer stamps four tags in the same call that stores the value (hosting-kv-set):

tag value
mw-fp the fingerprint: sha256: + the first 12 hex digits of SHA-256 of the value, or withheld:low-entropy
mw-set-by who asked for the write (the principal on the governed action)
mw-set-at the UTC instant of the write
mw-source paste (a person supplied it) or generate (minted server-side)

The fingerprint lets a person compare the two ends of a pairing (a webhook secret on the sender and the receiver, a key on the control instance and on a deployment) without either end being shown. It is published only when the value is estimated at 128 bits of entropy or more: its length × log2 of the alphabet its character classes span. Every generated value and every real API key, client secret or PEM clears that bar easily. A short human-chosen password does not, and a published hash prefix of one would let anyone guess it offline, so its fingerprint reads withheld:low-entropy. There is deliberately no keyed HMAC: its key would be one more secret both ends would have to read, which is exactly the reader this design removes. The operator's hosting::fingerprint is the reference implementation today. The portal-side twin, SecretFingerprint.Of in MeshWeaver.Mesh.Security, is owed: it arrives with the core change that adds the write-only secret control, and it must produce the same string byte for byte.

Enable/Disable never pass tags: set-attributes replaces the whole tag set and would wipe the fingerprint. Who toggled a secret is recorded on the governed action node instead.

The governed write path

Every write runs as system, through a governed action, never in a browser session and never under a person's own Azure credential:

  1. A person with the right to manage that instance opens the GUI (see Where each GUI lives below) and pastes a value into a password field, or presses Generate.
  2. The value is encrypted with the platform key protector (enc:) onto a Hosting/InstanceAction node on the control instance. It is refused, not stored, if encryption is unavailable. The field is cleared and nothing is bound back.
  3. The control plane authorises the invoker. Destructive operations (Delete, Purge) need a second person's approval.
  4. One operator Job, running under the writer identity, decrypts the value for exactly that Job and writes it through a mode-600 file (--file, never argv, never a log line). It stamps the tags, confirms the new version through list-versions (a metadata read), and then removes the ciphertext.

The verbs are hosting-kv-set (set, --generate), hosting-kv-status (status from metadata only) and hosting-kv-state (enable, disable, delete, recover, purge; a purge is refused unless the object is already soft-deleted). Their behaviour tests run against a stub az that plays the writer: it refuses secret show, so a verb that reads a value is red in CI.

Generate: shown once, or never

Pods pick the change up without a manual step

A new value reaches a pod in two stages. First the CSI driver's rotation poll re-reads the object and updates the synced Kubernetes Secret. Then the pod must restart, because environment variables are read only at start. The secret action does both:

A Reconcile that adds a key to a class is different: the driver syncs only the objects a class already names, and a pod mounting the old class never sees the new key. The governed path therefore follows a class change with a restart (the Reconcile plan's own step), so no one has to remember it.

Where each GUI lives

The rule is the same everywhere: one reusable write-only control, placed where the owner of the secret already looks.

secrets GUI owner
a deployment's vault objects (connection strings, sign-in and mail client secrets, registry instance key, AI platform keys) control instance → Deployments/<id> → Secrets (status list + Set / Generate / Disable / Delete / Recover) fleet admins
fleet pairings (Hosting-PlatformWebhookSecret, Hosting-OperationsSigningKey, control-inbox secrets, the per-deployment announcement key) the same page on each end of the pairing, compared by fingerprint fleet admins
per-user AI provider keys the user's Model providers settings (already write-only, enc: in the mesh) the user
an instance's first-run sign-in secret the instance's Setup wizard hand-off the instance's first admin
payment keys (Stripe secret key, webhook secret) Store → Payments admin, with a Test action that lists the webhook endpoints store admins

The platform pieces are the SecretStatus contract, the WriteOnlySecretSection control and the SecretInventorySection list over a scope of them. They are owed by the companion core change and not yet on main. An app wires those pieces up. It never builds its own form, and it never binds a value back into a view. Today the Deployments page's Set Key Vault secrets… dialog is the one GUI that exists; the rows above marked Store and the status list are what the companion changes add.

Break-glass

If the GUI or the control plane itself is down and a secret must change to bring it back, the vault's Owner-level administrators (subscription Owner) can grant themselves access. That is break-glass, never a procedure:

The CI guard

.github/scripts/check-manual-keyvault.py fails on any tracked doc, runbook, skill or script that holds a Key Vault secret data-plane command (the az keyvault secret verbs set, show, download, set-attributes, delete, purge, recover, backup and restore, or set-policy … --secret-permissions). There are two exceptions:

A command split over lines with a trailing backslash is joined before it is matched. A stale allow entry fails too, so the list only shrinks. Core runs the guard on itself. The node repos and Systemorph/Memex do NOT run it yet: MeshWeaver.Plugins still carries such commands, and wiring the shared node-repo-validate lane before its cleanup merges would red every Plugins pull request. The lane wiring follows that cleanup.

Where the fleet stands, and the migration

Measured on vault Systemorph: the vault is in access-policy mode, not RBAC mode, and six principals hold secret permissions on it:

principal secrets today target
CSI add-on identity (the READER, one identity for every pod in the cluster) get, list get
hosting-operator (operator Jobs, infra-deploy) get, list, set none: its secret work moves to the writer
github-actions-deploy (Memex helm-release / infra-deploy) get, list, set none
a user principal backup, delete, get, list, recover, restore, set none: break-glass is an Owner re-granting temporarily
two principals the directory no longer resolves get (+ delete, set) removed
secret-writer (new) — list, set, delete, recover, purge; never get

hosting-operator also holds the RBAC role Key Vault Secrets Officer on the vault, which is inert in access-policy mode. It changes nothing today and would become live, with value reads, if the vault flipped to RBAC, so it goes as well.

The order is chosen so that nothing breaks: build every replacement first, then narrow the policies, and do every change to the vault through infrastructure-as-code.

  1. The split, with access policies, vault-wide (now). Add the writer (list, set, delete, recover, purge) and narrow the reader to get. With no get, list still returns attributes and tags, and set covers attribute updates. Systemorph/Memex infra/estate.bicep declares both. The reader narrowing is flagged separately, so it can be switched on after a pod restart has been verified on get alone.

  2. Remove every value read from the governed path, so hosting-operator needs no get:

    • hosting-kv-copy: a copyFrom becomes a record reference to the same object, or a re-issue at the source entered through the GUI;
    • hosting::pg_password: the connection string is minted together with a per-instance database password in one writer step, instead of being composed from the admin password;
    • hosting-kv-ensure and hosting-signin-app: their existence checks use list, not secret show;
    • hosting-registry-register and hosting-kv-rotate: the registry key is issued straight into the vault and never read back;
    • hosting-image-mirror and hosting-pull-secret: the registry credentials are mounted through a CSI class in the operator's namespace, the same reader path the pods use;
    • hosting-deploy and Memex's helm-release: the helm "vault half" is retired, and each value in it becomes its own vault object mapped on the record.

    Each of these files is marked TRANSITIONAL in .github/manual-keyvault.allow until its rewrite lands. After that, hosting-operator and github-actions-deploy lose their access policies.

  3. Humans and orphans. The user principal's standing policy and the two unresolvable principals are removed. Break-glass is a subscription Owner re-granting temporarily (above).

  4. A new RBAC-mode fleet vault with per-secret scope. Access policies cannot scope below the vault, so the per-secret step is a NEW vault in RBAC mode. It uses two custom roles, because the built-in Secrets Officer and Secrets User roles both include metadata reads:

    • Secret Writer: …/secrets/readMetadata/action, …/secrets/setSecret/action, …/secrets/delete, …/secrets/recover/action, …/secrets/purge/action;
    • Secret Reader: …/secrets/getSecret/action only.

    The migration, per record:

    1. the writer re-enters or generates each of the record's objects in the new vault through the GUI. Values are never copied out of the old vault, because nothing may read them;
    2. the reader is assigned per secret, from the objects the record's SecretProviderClass lists. A per-instance reader identity then replaces the one cluster-wide CSI identity;
    3. the record's keyVault moves to the new vault and a Reconcile rolls the pods onto it;
    4. the old objects are deleted, recoverable for the retention period.

    The legacy objects that belong to no instance (disk-encryption keys, certificates) stay in the old vault.

The identities, the policies and later the custom roles and assignments are provisioned in Systemorph/Memex infra/estate.bicep, through the governed InfraDeploy action (what-if, then an approved deploy). They are never created with az by hand. Which identity each instance holds is in Systemorph/Memex docs/control-instance.md → Secrets and identities.