Skip to content

Secret references

Desired state names a secret and never contains one. A host resolves the name to a value while applying, and the value exists nowhere that Datum writes.

Proposed behaviour

The field names and the backend interface are proposed. The boundary is decided, and it is recorded as ADR-0013, which is that a reference travels through the repository and a value never does.

Why a reference rather than a value

Every managed host reads the whole repository. A secret committed there, encrypted or not, is readable by every machine under management, and encrypting it does not help because all of those machines would need the key.

Holding a reference instead means the repository describes which secret a file should contain without containing it, so the blast radius of one compromised host stops at that host's own secrets.

A whole file that is a secret

datum: v1alpha1
type: File

name: app-tls-key

requires:
  - Directory[app-tls]

desired:
  path: /etc/app/tls/server.key
  owner: app
  group: app
  mode: "0600"
  secretRef: app/tls-key

secretRef is mutually exclusive with content, source and template. The file's entire content is the resolved secret, and sensitive is implied and not something that has to be remembered.

A secret inside a configuration file

datum: v1alpha1
type: File

name: app-config

desired:
  path: /etc/app/app.yaml
  owner: app
  group: app
  mode: "0640"
  template: files/app.yaml.tmpl
fleet/roles/app/files/app.yaml.tmpl
site: {{ labels.site }}
database:
  host: db.{{ labels.site }}.example.com
  password: {{ secrets.db_password }}

Two placeholder namespaces appear there and they are resolved in different phases, which is the thing to understand about them.

{{ labels.site }} is substituted during resolution, so the rendered value is in the effective manifest and contributes to its digest. {{ secrets.db_password }} is left alone by resolution and resolved on the host during apply, so the manifest carries the placeholder.

$ datum render --host web-001

File[app-config]   /etc/app/app.yaml
  template   files/app.yaml.tmpl
  secrets    db_password

The manifest naming which secrets a resource consumes, without their values, is what keeps a rendered file reviewable. A reader can see that this file needs one secret and what it is called.

Where the value comes from

/etc/datum/agent.yaml
secrets:
  provider: file
  path: /etc/datum/secrets

The backend is agent configuration, never desired state. A repository that could nominate where secrets come from could nominate a source an attacker controls, which is the same reasoning that keeps trust anchors out of the repository, and /etc/datum/secrets/ is protected as one for the same reason.

Provider Resolves a reference by
file Reading <path>/<reference>, root-owned at mode 0600
exec Not offered, see below
Others An out-of-process backend speaking the provider protocol

The file provider is the trivial case and is genuinely useful, because it turns secret distribution into a provisioning problem a fleet may already have solved. A vault, an age-encrypted store with a host key, or a platform's instance credential service are all out-of-process backends behind the same interface.

There is no backend configured by supplying a command to run. A command in a configuration field is arbitrary root execution arriving through a different door, and a backend that needs to run something is an extension installed the way extensions are installed.

Resolution failure fails the resource

A reference the backend cannot resolve makes the resource failed, and dependents are blocked.

failed   File[app-config]
         secret   db_password could not be resolved
         reason   no such key in provider "file"

Resolving to an empty value would be the harmful alternative. An empty password in a configuration file is syntactically valid, would be written successfully, would pass configuration validation, and would leave an application running with no credential. A file that was not written is recoverable, and a file written with an empty secret may already have been read.

The existing file is left exactly as it was, because a secret-bearing file is written through the same stage, validate, activate sequence as any other, and a failure before the rename changes nothing.

What this unblocks

datum: v1alpha1
type: User

name: deploy

desired:
  state: present
  shell: /bin/bash
  passwordRef: users/deploy-hash

User gains passwordRef, which resolves to a password hash, not a password. The hash is still secret material, which is why it is a reference, and Datum does not compute hashes because doing so would mean handling the plaintext.

An authorized_keys file is a File with secretRef, which is not strictly secret and is authorisation-bearing, so the same handling is wanted. Placing a public key that grants shell access is a change to keep out of a repository every host can read.

What is deliberately not solved

Datum does not store secrets, generate them, rotate them, or know when they changed.

Rotation is the consequence that needs stating plainly. Changing a value in the backend changes nothing in Git, so the manifest digest is unchanged and datum affected reports nothing. A host picks up the new value on its next pass, because it resolves the reference every time, and nothing in Datum records that the value differs from the one applied before.

That is consistent with the digest answering what the repository says rather than what a host holds, and it means rotation has to be tracked by whatever performs the rotation.

Open question

Whether a resource should be able to state that a secret's value changed, so that a restartOn edge can fire when a certificate is renewed, is undecided. Detecting it needs the host to remember a digest of a resolved secret between passes, which is cross-pass state holding a fingerprint of secret material, and that is a worse thing to store than it first appears.

Important limitation

A secret reference protects the value from the repository and not from the host. The resolved value is written to a file on that machine as root, so anything with root there can read it, and an agent holding a backend credential can fetch every secret that credential permits. Narrowing what one host may resolve is the backend's job, and Datum's contribution is that the reference says which secrets a host needs, so a backend has something to authorise against.