Credential Inventory
|
This page records where a credential lives and what it is for. It never contains a credential value, and no value may be added to it. The documentation site is published; treat every page here as public. |
1. Why this page exists
Credentials for this estate accumulate in whichever place was convenient at the time — a shell history, a chat transcript, an agent’s memory file, a teammate’s note. That spreads the value across places nobody audits, and it leaves nobody able to answer the two questions that matter in an incident: what is this key for, and where is the authoritative copy.
This page answers both. It is the index; the values live on disk in one place, described below.
2. The storage rule
Credential values live on disk under ~/dev/ems/.local/, which is gitignored, at mode 600 in
directories at mode 700. They are split across two locations by blast radius, and the split is
enforced by tooling rather than convention.
| Tier | Location | Reachable by an agent |
|---|---|---|
Low blast radius — development and stage |
|
Yes, directly. These are read constantly, are cheap to rotate, and compromise costs a throwaway environment. |
High blast radius — production, and any identity-provider secret |
|
No. Usable but not readable — see [Using a protected credential]. |
The second tier is backed by deny rules in the agent harness configuration, which refuse reads of
./secrets/ and .//.key. That is why the protected files carry a .key extension inside a
directory called secrets — *both halves of the name are load-bearing, and renaming either one
removes what protection there is.
|
Know what this guard is and is not. The deny rule is evaluated against the shape of the command, so it reliably stops a direct read — but ordinary shell composition gets past it. A subshell and a pipe are enough. Treat it as defence in depth against accidental or naive disclosure, not as a sandbox boundary. What it genuinely buys: a session cannot casually What it does not buy: any protection against a determined caller. An agent with shell access can
read anything the operator can read. If a credential must be unreachable by an agent, it cannot
live on the operator’s disk at all — it belongs in a secret manager the agent has no path to. See
|
|
If a tool reports that it cannot read a credential under |
| Concern | Rule |
|---|---|
Credential inventory |
This page. Purpose, consumer, storage path, rotation posture. Never a value. |
Non-secret identifiers |
This page or Environments. Tenant ids, client ids, issuer URLs and hostnames are configuration, not secrets, and are more useful written down than hidden. |
Deployed credentials |
Kubernetes |
Three things must never hold a credential value: a Git repository, a documentation page, and an AI agent’s persistent memory. A memory file or a design document may name the credential and point at its path, which is what makes this inventory the thing worth pointing at.
[[Using a protected credential]] == Using a protected credential
.local/bin/ems-api makes an authenticated admin-service call in any environment. It opens the key
file inside the process, where the deny rule does not reach, passes it to curl through a header
file rather than argv so it cannot leak through ps, and shreds that file on exit. The value is
never printed.
.local/bin/ems-api prod-v2 /management/info
.local/bin/ems-api stage '/api/events?page=0&size=5'
.local/bin/ems-api prod /api/events -- -X POST -d @body.json -H 'Content-Type: application/json'
Anything after a bare -- is passed to curl verbatim. prod is the legacy hostname and prod-v2
the current line; see Environments.
.local/bin/woo-api does the same for a customer site’s WooCommerce REST API. It sends the
storefront key as HTTP Basic credentials through a curl config file, never on the command line and
never in the query string (which would write it into the host’s access logs). It is read-only by
default: any request that changes data, or carries a body, is refused unless the command includes
--write, so every write is visible in the command that runs it.
.local/bin/woo-api wcsc '/orders/12345?_fields=id,status,total'
.local/bin/woo-api wcsc --write /orders/12345 -- -X DELETE
Paths are relative to the site’s /wp-json/wc/v3 root. A DELETE without force=true moves an order
to trash, which can be undone from the WooCommerce admin until the trash is emptied.
The same pattern already exists for the federation integration key, in .local/set-csa-key.sh,
which reads the key, patches a cluster secret, verifies by fingerprint comparison and restarts the
deployment — all without disclosing the value. When a new access pattern needs a protected
credential, add a subcommand or a sibling script. That is the intended way to extend this.
3. Inventory
The Storage column gives the path under ~/dev/ems/.local/. A path under credentials/ is
directly readable; a path under secrets/ is protected and must be used through the wrapper.
3.1. Service API keys
admin-service authenticates machine callers with an API key passed as the X-API-KEY header.
A key is bound to one organisation and carries a set of permission roles.
| Credential | Purpose | Storage | Notes |
|---|---|---|---|
admin-service production ADMIN key |
Management endpoints and administrative API calls against production. Carries |
|
Works against both production hostnames. Rejected by stage. Use |
admin-service stage ADMIN key |
Administrative API calls and management endpoints against stage. |
|
Bound to one organisation but effective across organisations for imports and management. Reset by the nightly production clone — see [Stage keys do not survive the nightly clone]. |
admin-service dev key, single organisation |
The admin-portal development gateway’s outbound key on its proxy route. |
|
Deliberately single-organisation: a key bound to two organisations makes the organisation lookup ambiguous and participant import fails. |
CSA integration key |
Outbound calls to the external cycling federation membership and licensing API. |
|
Not directly readable. Applied to a cluster secret by |
3.2. Customer storefronts
A customer’s WordPress site runs WooCommerce, which issues REST API keys as a consumer key and consumer secret pair, each bound to a WordPress user and to a permission level of Read, Write or Read/Write. These keys belong to the customer’s site, not to admin-service.
| Credential | Purpose | Storage | Notes |
|---|---|---|---|
WooCommerce REST key, Western Cape Schools Cycling production |
Reading and maintaining storefront orders on the customer’s live shop — reconciliation against EMS, and removing duplicate orders. |
|
One line, |
3.3. Identity provider
| Credential | Purpose | Storage | Notes |
|---|---|---|---|
Entra client secret, admin-portal development |
The gateway’s OIDC client credential for the development app registration. |
|
Loaded at runtime from an environment variable, never inlined into a configuration file. An identity-provider secret is treated as high blast radius regardless of environment. Flagged for rotation — see Rotation. |
The matching non-secret identifiers — tenant id, client id, issuer and discovery URLs, registered redirect URIs, requested scopes — are configuration rather than credentials. They live in the service’s own configuration and in Environments.
3.4. Databases
| Credential | Purpose | Storage | Notes |
|---|---|---|---|
MySQL read user, production cluster |
Read-only SQL against the production cluster through a bastion tunnel. |
|
Gitignored explicitly by name, and readable — no deny pattern matches it. If a tunnel is up on
the expected port but MySQL returns an access-denied error for the local shell account, the tunnel
is fine and only the credentials are missing. Connection procedure is in the
|
MySQL, legacy server |
SQL against the legacy database on the external host. |
See the |
A separate estate with its own access path. |
3.5. Observability
| Credential | Purpose | Storage | Notes |
|---|---|---|---|
Grafana viewer service-account token |
Reading logs, metrics and dashboards through the Grafana API and the MCP server. |
|
Per-user token, Viewer role. |
Metrics remote-write tokens |
Pushing metrics from outside the cluster into the monitoring stack. |
|
Write-scoped. Not needed for reading telemetry. |
4. Adding a new credential
-
Decide the tier. Production, or any identity-provider secret, goes to
.local/secrets/<name>.keyand is reached through the wrapper. Development and stage go to.local/credentials/<name>.txtand are read directly. Write it withumask 077, using a name that says what it opens rather than what it is called upstream. -
Add a row to the table above in the right section: purpose, storage filename, and any constraint a future reader would otherwise have to rediscover.
-
If the credential is consumed by a workload, apply it to the cluster as a
Secretand record which deployment reads it. -
Never paste the value into a commit message, a work item, a documentation page, or an agent memory file.
The naming convention matters more than it looks. A file called key.txt is unusable a month
later; admin-service-stage-admin.key tells the next reader the service, the environment and the
permission level without opening it.
5. Verifying a value without disclosing it
Compare fingerprints rather than printing secrets. This is the check to run when a call is failing and you need to know whether the deployed value matches the local one:
sha256sum <(tr -d '[:space:]' < ~/dev/ems/.local/secrets/<file>) | cut -c1-8
Take the same eight characters from the decoded cluster secret and compare. A mismatch localises the fault immediately; a match rules the credential out and sends you to look at permissions instead.
A trailing newline is the classic false alarm here. It produces exactly the same authentication failure as a wrong key, so strip whitespace on both sides before comparing.
6. Rotation
There is no automated rotation. Rotate on these triggers:
-
Disclosure. A value that has appeared in a chat transcript, a shared terminal recording, or any system outside the operator’s own machine is disclosed and must be rotated. The Entra development client secret listed above is in this state and is flagged for rotation once the integration it supports is verified end to end.
-
Expiry. The Entra client secret carries a provider-set expiry. Confirm the date in the portal rather than assuming it.
-
Departure. Any person-scoped token when that person’s access should end.
When a credential is rotated, update the file under .local/secrets/, apply it to every consumer,
and correct this page if the storage path or purpose changed. The value’s location is the only
thing recorded here, so a rotation that keeps the same path needs no edit — which is the intended
property.
|
[[Stage keys do not survive the nightly clone]] Stage keys do not survive the nightly clone. The stage database is refreshed from production overnight, which truncates the API key tables and re-inserts them from side tables held only in the stage schema. A permission written to the live table lasts until the next refresh. To make it permanent, write the same row to the corresponding seed table as well. Those seed tables are maintained by hand and are deliberately not tracked in a repository, so nothing in Git tells you what stage’s keys are — read the tables. |
7. Related
-
Environments — hostnames, namespaces and schemas per environment.
-
Management API Access — what the production ADMIN key is used for.
-
Observability Access — what the Grafana token is used for.
-
CI and Release Pipeline Traps — the repository secrets each service’s pipeline needs.