Secrets Management Guide¶
This guide covers the full lifecycle of fleet secrets: creation, scoping, auditing, revealing, and rotating. It applies to any fleet running the MAC control plane.
Overview¶
Secrets are durable, encrypted credentials stored in the control-plane database. Each secret:
- Is encrypted at rest with Fernet symmetric encryption (key derived from
MAC_SECRET_KEYvia HKDF). - Is access-gated by a scope that names who can request it (by agent ID, capability tag, or tenant membership).
- Is audited on every request — granted or denied — so every access is traceable in the audit trail.
- Can only be revealed once per request via a single-use, time-limited handle.
Lifecycle: set → scope → audit → reveal → rotate¶
1. Set a Secret¶
Create a secret with a name, plaintext value, and a scope dict that controls who can request it:
from mac.services import ControlPlane
cp = ControlPlane.in_memory() # use your real store in production
# Scope by agent ID — only worker-1 can request this secret
secret = cp.secrets.create_secret(
name="deploy-credential",
value="<plaintext-deploy-token>",
scopes={"agents": ["agent_<worker-1-id>"]},
created_by="hub",
)
print(secret.id) # e.g. secret_abc123
The plaintext is encrypted immediately; SecretRecord.to_dict() always
returns "***REDACTED***" in the value field so logs and API responses
never expose the real value.
2. Define a Scope¶
A scope dict controls which agents can access the secret. Three scope types can be combined:
| Scope key | Meaning |
|---|---|
agents |
List of agent IDs that may request the secret |
capabilities |
List of capability tags; any agent with a matching capability is allowed |
tenant_ids |
List of tenant IDs; the requesting agent's machine must be tenanted to one of these |
All conditions within a single scope are OR-combined; but if tenant_ids
is present the requesting agent's machine must satisfy that tenant restriction
first before the agent/capability check applies.
Examples:
# Only a specific agent
scopes = {"agents": ["agent_<worker-1-id>"]}
# Any agent with the 'deploy' capability
scopes = {"capabilities": ["deploy"]}
# Any agent tagged 'admin' or 'dispatch'
scopes = {"capabilities": ["admin", "dispatch"]}
# Any agent in tenant-A, regardless of capability
scopes = {"tenant_ids": ["tenant_<alpha-id>"]}
# Specific agent AND the machine must be in tenant-A
scopes = {"agents": ["agent_<worker-1-id>"], "tenant_ids": ["tenant_<alpha-id>"]}
3. Request a Handle¶
An agent requests a time-limited, single-use handle. The control plane checks the scope, machine trust, and enabled flag, then writes an audit record before returning the handle:
handle = cp.secrets.request_secret(
secret_id_or_name="deploy-credential",
accessor_agent_id="agent_<worker-1-id>",
purpose="ci-deploy",
ttl_seconds=300, # handle expires in 5 minutes
)
# handle.granted == True → proceed to reveal
# handle.granted == False → AuthorizationError was raised before this line
If the scope or machine trust check fails, AuthorizationError is raised and
the denial is written to the audit trail.
4. Reveal the Secret¶
Use the handle to retrieve the plaintext. This marks the handle as spent:
plaintext = cp.secrets.reveal_secret(
secret_id=handle.secret_id,
audit_id=handle.audit_id,
accessor_agent_id="agent_<worker-1-id>",
)
# Use plaintext once, then discard — the same handle cannot be redeemed again.
A second call with the same audit_id raises AuthorizationError.
5. Rotate the Secret¶
Replace the ciphertext with a new value without changing the secret ID or name.
A ROTATED audit record is written automatically:
updated = cp.secrets.rotate_secret(
secret_id_or_name="deploy-credential",
value="<new-plaintext-deploy-token>",
actor="hub",
)
print(updated.rotated_at) # ISO timestamp of the rotation
Scoped-Token Authorization Patterns¶
Agent-API-Key Pattern¶
An agent that needs its own API key to call an upstream service should store the key scoped to its own agent ID:
cp.secrets.create_secret(
"worker-1-api-key",
"<api-key>",
{"agents": ["agent_<worker-1-id>"]},
"hub",
)
No other agent can request this key even if they know the name.
Shared Deploy Credential Pattern¶
Multiple deploy agents share a credential by scoping via capability:
cp.secrets.create_secret(
"registry-push-token",
"<push-token>",
{"capabilities": ["deploy", "image-builder"]},
"hub",
)
Any new deploy agent automatically gains access when it's registered with the
deploy or image-builder capability.
Client Principal And Profile Pattern¶
New control clients must not receive the shared MAC_API_TOKEN. On the hub,
mac admin client enroll creates a distinct scoped bearer and stores only its
sha256: hash in $MAC_CLIENT_PRINCIPALS_FILE (default
$MAC_HOME/client-principals.json). The adjacent audit JSONL contains client
ID, scopes, version, actor, and lifecycle event, but no token or full stored
hash. Both files are mode 0600 under a mode-0700 MAC home.
The default scopes are read, write, and dispatch. secret, deploy, and
admin issuance fails unless the hub operator passes --allow-elevated.
Renewal rotates the bearer and immediately invalidates its predecessor;
revocation affects only that client. The API reads the hashed registry on each
file change, so neither action needs a service restart.
Stream the one-time JSON enrollment output directly into
mac admin client profile install -. The local profile YAML holds connection,
host-key, scope, expiry, and credential-reference metadata only. Its bearer is
stored separately under ~/.mac/credentials/clients/ with mode 0600 and is
redacted from normal profile output. Unknown manifest fields, credential-bearing
URLs, and strict SSH profiles without pinned host identity are rejected.
The shared administrator MAC_API_TOKEN is not a client issuance mechanism. The
bounded client profile migrate-legacy command requires
--allow-legacy-admin-token, makes a secure first-import backup, and labels the
resulting authority accurately as admin until it is replaced with scoped
enrollment.
See SSH Client Bootstrap Contracts for managed
mac admin login, renewal, revoking logout, the manual SSH recovery workflow, and
failure rules.
Git-Host Credential Pattern¶
Repository credentials have two distinct layers:
- The encrypted MAC secret record controls durable storage and audited reveal.
- The worker or Kubernetes Job environment supplies the host-specific variable that Git operations actually resolve.
Creating a vault secret named github.token does not automatically place it
in a worker process. Fleet deploy resolves MAC_DEPLOY_GH_TOKEN, GH_TOKEN,
GITHUB_TOKEN, then the operator's authenticated gh keychain and writes the
result to managed runtimes as GH_TOKEN. The value is streamed over SSH stdin
through a one-use mode-0600 file; it is not part of the remote command.
Kubernetes task and review Jobs read optional GH_TOKEN, GITHUB_TOKEN, and
GITEA_TOKEN keys from the runner's configured Secret (default
mac-api-config). Keep Git tokens out of fleets.yaml, committed fleet specs,
task metadata, operator CLI arguments, and logs. The internal Git resolver may
place a credential in the argv of the one Git subprocess that needs it;
process inspection on an execution host is therefore privileged access.
At runtime, GitHub HTTPS access resolves GH_TOKEN, then GITHUB_TOKEN, then
the host-mode MAC_TASK_GIT_TOKEN fallback. Gitea resolves GITEA_TOKEN, then
the fallback. The credential is injected only into the individual Git command;
the worker restores a credential-free origin immediately afterward. Review
Jobs receive the optional Git-host keys but deliberately do not receive
MAC_SECRET_KEY.
Pure workers default worker.github_credentials_required to true. Deployment
therefore validates gh auth status before draining or replacing a fresh node.
The OpenShell executor forwards Git-host variables through its private
mode-0600 environment upload, rather than copying host SSH keys or exposing a
token in sandbox create arguments.
Repository access writes fleet_learning:repository_access common-memory
records so reviewer routing can reuse proven success and avoid a recent auth
failure. Those records contain only the credential source name (for example
env:GH_TOKEN), never its value. They are operational routing data, not a
replacement secret store. See Fleet Operational Learning.
Tenant-Isolated Secret Pattern¶
A credential that belongs to one tenant's workload should be scoped to that tenant so no agent from a different tenant can ever request it:
cp.secrets.create_secret(
"tenant-alpha-db-password",
"<password>",
{"tenant_ids": ["tenant_<alpha-id>"]},
"hub",
)
Agents whose machines are tagged with a different tenant policy will always
receive AuthorizationError.
Audit-Trail Interpretation¶
Every request_secret, reveal_secret, rotate_secret, and
resolve_secret_value call writes a row to secret_access_audit.
audits = cp.secrets.list_audits(secret_id="secret_<id>")
for a in audits:
print(a.result, a.accessor_agent_id, a.purpose, a.revealed_at)
result field |
Meaning |
|---|---|
granted |
Scope check passed; handle was issued |
denied |
Scope or trust check failed; no handle issued |
rotated |
Secret value was rotated; actor is recorded in accessor_agent_id |
Key fields:
created_at— when the request was processedexpires_at— when the handle expires (only set forgrantedrows)revealed_at— when the plaintext was revealed (non-null = the handle was redeemed)purpose— free-text description provided by the caller (e.g."ci-deploy")
A granted row with revealed_at IS NULL means a handle was issued but never
redeemed — either the TTL expired or the agent didn't follow through.
Worked Examples¶
Example A: Slack Bot Token¶
# 1. Set the secret, scoped to the messaging agent by capability
cp.secrets.create_secret(
"slack-bot-token",
"xoxb-<actual-token>",
{"capabilities": ["messaging"]},
"hub",
)
# 2. Messaging agent requests a handle before sending a message
handle = cp.secrets.request_secret(
"slack-bot-token",
accessor_agent_id="agent_<messaging-agent-id>",
purpose="send-notification",
)
# 3. Agent reveals and uses the token
token = cp.secrets.reveal_secret(handle.secret_id, handle.audit_id, "agent_<messaging-agent-id>")
# use token to call Slack API ...
Example B: Deploy Credential¶
# 1. Store during fleet setup
cp.secrets.create_secret(
"registry-deploy-key",
"<deploy-key>",
{"capabilities": ["deploy"]},
"hub",
)
# 2. Deploy agent requests a handle before pushing an image
handle = cp.secrets.request_secret(
"registry-deploy-key",
accessor_agent_id="agent_<worker-1-id>",
purpose="image-push",
ttl_seconds=120,
)
cred = cp.secrets.reveal_secret(handle.secret_id, handle.audit_id, "agent_<worker-1-id>")
# run docker push with cred ...
# 3. Rotate after a security event
cp.secrets.rotate_secret("registry-deploy-key", "<new-deploy-key>", actor="hub")
Example C: Agent API Key for an Upstream LLM Provider¶
# 1. Operator sets the key during provisioning
cp.secrets.create_secret(
"openai-api-key",
"sk-<actual-key>",
{"capabilities": ["llm-caller"]},
"hub",
)
# 2. The model router resolves it in-process (no handle dance)
value = cp.secrets.resolve_secret_value("openai-api-key", purpose="router", accessor="router")
# value is None if the secret is absent or disabled — fallback to env
resolve_secret_value is intended for control-plane-internal callers (e.g.
the model router on the hub) that decrypt a secret they already own. It
still emits an audit record but skips the request/reveal two-step.
Disabling and Deleting Secrets¶
Disable a secret (leaves audit trail intact):
cp.store.execute("UPDATE secrets SET enabled = 0 WHERE id = ?", (secret.id,))
# All future request_secret calls will raise AuthorizationError.
# resolve_secret_value returns None instead of raising.
Hard-delete a secret (removes ciphertext and row; audit rows cascade away):
Prefer disabling over deleting when you want to keep the audit history and allow the name to be reclaimed later.
Security Notes¶
- Untrusted machines: agents on machines with
trusted = Falseare always denied, regardless of scope. Ensure machines are marked trusted only after fleet validation. - Single-use handles: each
request_secretproduces exactly one handle. A single agent cannot reuse the same handle to reveal the secret twice. - TTL enforcement: handles expire after
ttl_seconds(default 300 s). Expired handles raiseAuthorizationErroron reveal. - Audit on denial: every denied request is logged; monitor for repeated denials as a signal of a misconfigured scope or a credential-harvesting attempt.
- Key rotation:
MAC_SECRET_KEYis used to derive the Fernet key. If the environment key is rotated, all existing ciphertext becomes unreadable until the secrets are re-encrypted. Plan key rotation carefully. - Operational memory is not a secret store: repository-access learnings may
name
env:GH_TOKENor another mechanism, but must never contain token values, authenticated URLs, or raw secret-bearing Git output.