# Secret Schema — Layer 3 Reusable Entity Type # KNO Schema Version: 0.1.1 # # A Secret entity is the manifest record for a single managed credential: # WHERE it lives (OpenBao path), HOW it's rotated (procedure reference), # WHO consumes it (consumer list + restart contracts), and WHEN it's due # for rotation (cadence + timestamps). The rotator (services/pspace-rotator) # loads these entities and runs the rotation state machine. # # DESIGN DECISIONS: # - Single schema per managed credential; one .kno file per secret in # `content/secrets/`. The directory IS the manifest. # - Secret VALUES never live in the schema — only the OpenBao path # (`storage.bao_path`). Operationally enforced + lint-checked. # - Rotation procedure is FACTORED OUT into rotation-procedure-schema # so multiple secrets that rotate the same way (e.g., all Cloudflare # scoped tokens) share one procedure entity. # - `parent_credential_xri` references ANOTHER secret entity that holds # the credential used to rotate THIS secret. This forms the rotation # dependency graph the orchestrator walks. # - Consumers are inline (not referenced) because the consumer list is # intrinsic to the secret — moving it elsewhere would split closely # coupled data. # # THREE-GATE TEST: # Gate 1 (Distinctness): YES — a managed-secret manifest entry is # fundamentally different from any existing entity. It is not a # service-config (which describes a service binding, not a credential), # not a connector (which describes a provider integration, not a # specific secret instance), and not a config (which wraps imported # YAML files). # Gate 2 (Reusability): YES — at Phase 1A introduction we have ~12 # managed secrets (Cloudflare API token, SendGrid keys, Azure SP # client secrets, OpenBao root token shards, GitHub deploy key, # Tailscale auth key, KEYCLOAK_ADMIN_CLIENT_SECRET, EMAIL_WEBHOOK_TOKEN, # etc.) and the population only grows. # Gate 3 (Clarity): YES — one entity per secret keeps the manifest # greppable and the rotation audit trail per-secret-traceable. # # RELATED: # - rotation-procedure-schema.kno — the procedure referenced by # `rotation.procedure_xri` # - docs/architecture/secret-rotation.md — the design that motivated # this schema # - .github/instructions/security-defense-layers.instructions.md — # the four-layer defense model that gates secret-touching changes # ============================================================================= # SCHEMA DECLARATION # ============================================================================= $schema: kno@0.0.9 # ============================================================================= # IDENTITY # ============================================================================= id: 01KQYR8F3TZJ5VMW1NCAB2DEHK slug: secret-schema type: spec version: 0.1.1 # ============================================================================= # STANDARD TIER # ============================================================================= title: "Secret Schema" purpose: | Define the schema for Secret entities — manifest records for managed credentials rotated by the `pspace-rotator` package. **What is a Secret entity?** A `.kno` file in `content/secrets/` declaring a single managed credential: its OpenBao storage path, rotation cadence, rotation procedure (referenced from `rotation-procedure-schema`), parent credential (the credential used to rotate it), and consumer list. **What is NOT in a Secret entity?** The credential VALUE. Secret entities are pure metadata. Values live exclusively in OpenBao at `storage.bao_path`. This separation means the `content/secrets/` directory is safe to commit to version control and inspect without elevated access. **Manifest semantics:** The set of all `content/secrets/*.kno` files IS the rotation manifest. The rotator walks the directory at startup, builds the dependency graph from `rotation.parent_credential_xri` references, and refuses to proceed if any secret has no procedure or any procedure has no parent credential resolution path. # ============================================================================= # RICH TIER — Provenance & Taxonomy # ============================================================================= provenance: origin: id: 01KQYR8F3TZJ5VMW1NCAB2DEHK timestamp: "2026-04-27T00:00:00Z" tool: manual taxonomy: topics: - secrets - rotation - security - manifest - operations keywords: - secret - credential - rotation - openbao - bao - manifest - pspace-rotator # ============================================================================= # RICH TIER — Relationships # ============================================================================= relationships: depends_on: - xri: "kno://specs/kno-spec" reason: "Conforms to KNO format specification v0.0.9" - xri: "kno://specs/rotation-procedure-schema" reason: "Each secret references a rotation-procedure entity via rotation.procedure_xri" composes: - xri: "kno://specs/identity-schema" reason: "Layer 1: id, slug, provenance" - xri: "kno://specs/history-schema" reason: "Layer 1: _history, changelog" - xri: "kno://specs/quality-schema" reason: "Layer 1: quality, validation" related_to: - xri: "kno://specs/service-config-schema" reason: "Service configs reference secrets via secrets_xri; secret entities ARE the targets of those references" - xri: "kno://specs/connector-schema" reason: "Many secrets are credentials for connector-described provider integrations" enables: - xri: "kno://content/secrets/*" reason: "Secret manifest instance files consumed by pspace-rotator" # ============================================================================= # RICH TIER — Quality # ============================================================================= quality: completeness: 0.85 last_reviewed: "2026-04-27" review_status: draft reviewed_by: "claude" # ============================================================================= # HISTORY # ============================================================================= _history: retention: full format: changelog changelog: - version: "0.1.1" date: "2026-04-27" author: "claude" summary: "Add recovery_procedure_xri for self-cycle break-glass paths" changes: - "Added optional rotation.recovery_procedure_xri — used in --mode break-glass when the standard procedure cannot run because the old credential is unusable (e.g., SSH key lost)" - "Added validation rule self-cycle-recovery-procedure: secrets with parent_credential_xri == self MUST declare recovery_procedure_xri (warning) so break-glass has a documented path" - "Clarified that self-cycle parent_credential_xri is benign in normal mode (old key authorizes new-key install) and only requires break-glass when the old key is unusable" - version: "0.1.0" date: "2026-04-27" author: "claude" summary: "Initial secret manifest schema" changes: - "Created for pspace-rotator Phase 1A (docs/architecture/secret-rotation.md)" - "Fields: storage.bao_path, rotation.{cadence_days,last_rotated,rotation_due_at,procedure_xri,par\ ent_credential_xri}, consumers[], audit" - "Secret values explicitly forbidden in schema; only OpenBao path stored" - "Consumer list inline (not referenced) — intrinsic to the secret" # ============================================================================= # REQUIRED FIELDS # ============================================================================= fields: # --------------------------------------------------------------------------- # IDENTITY # --------------------------------------------------------------------------- id: type: string format: ulid required: true description: "ULID of this Secret entity (immutable birth identity)" slug: type: string required: true pattern: "^[a-z0-9][a-z0-9-]*$" description: | Human-readable identifier matching the file basename. Examples: `cloudflare-api-token`, `sendgrid-api-key`, `azure-sp-pspace-rotator`. type: type: string enum: [ secret ] required: true version: type: string format: semver required: true name: type: string required: true description: "Short human-readable name for the secret (1 line)." description: type: string required: true description: | What this secret is for and what would break if it were lost or compromised. Included verbatim in audit-log entries. # --------------------------------------------------------------------------- # STORAGE — where the value lives (NEVER the value itself) # --------------------------------------------------------------------------- storage: type: object required: true description: | Where the credential value is persisted. The schema NEVER contains the value — only the lookup path. Lint enforces the absence of any field literal that looks like a credential. properties: bao_path: type: string required: true pattern: "^[a-z0-9][a-z0-9_/.-]*$" description: | OpenBao KV v2 mount-relative path (e.g., `prod/CLOUDFLARE_API_TOKEN`). The full URI is `bao://pspace/` resolved by the rotator and consumers at runtime. bao_key: type: string required: false description: | Optional sub-key inside the BAO entry when the path holds a structured object (e.g., `value`, `private_key`). Default when omitted: `value`. backup_path: type: string required: false description: | Optional Azure Key Vault secret name used as the disaster-recovery mirror. The rotator writes to BOTH paths on rotation. Omit if the secret is not backed up to AKV (e.g., short-TTL ephemeral creds). # --------------------------------------------------------------------------- # ROTATION — cadence, procedure reference, parent credential # --------------------------------------------------------------------------- rotation: type: object required: true description: "Rotation policy and lineage for this secret." properties: cadence_days: type: integer required: true minimum: 1 description: | Maximum age before rotation is required. The rotator alerts when `now - last_rotated > cadence_days * 0.9` and refuses cooperation (status `overdue`) when exceeded by >7 days unless `--force` is passed. last_rotated: type: string format: date required: false description: | ISO-8601 date of the most recent successful rotation. Updated by the rotator on every successful run; absent on the first rotation. rotation_due_at: type: string format: date required: false description: | Cached `last_rotated + cadence_days` for cheap dashboard reads. Recomputed by the rotator on every successful rotation. NEVER edited by hand — it desynchronizes from `last_rotated`. procedure_xri: type: string required: true pattern: "^kno://content/rotation-procedures/[a-z0-9-]+$" description: | XRI reference to the rotation-procedure entity that describes HOW to rotate this secret (provider, generate, verify, revoke). Multiple secrets MAY share one procedure (e.g., every Cloudflare scoped token uses `cloudflare-scoped-token`). parent_credential_xri: type: string required: false description: | XRI of ANOTHER secret entity (also `kno://content/secrets/...`) whose value is needed to rotate THIS secret. Forms the rotation dependency graph. OMIT for root credentials (e.g., the OpenBao unseal key, Azure break-glass account); the rotator will require `--break-glass` mode to rotate them. MAY equal the secret's own XRI (self-cycle) when the old value is what authorizes installing the new value (e.g., SSH keypairs where the old private key writes the new pubkey to authorized_keys). Self-cycles are valid in `normal` mode; if the old value is unusable, use `--mode break-glass` and declare `recovery_procedure_xri`. examples: - "kno://content/secrets/cloudflare-api-token-parent" recovery_procedure_xri: type: string required: false pattern: "^kno://content/rotation-procedures/[a-z0-9-]+$" description: | XRI of an alternate rotation-procedure entity used ONLY in `--mode break-glass`. Required (warning-level) when `parent_credential_xri` equals the secret's own XRI, because the standard procedure cannot run if the old credential is unusable. Should describe an out-of-band recovery path (Azure VM serial console, OpenTofu re-provision, hardware token, etc.). examples: - "kno://content/rotation-procedures/ssh-keypair-recovery-via-azure-c\ onsole" # --------------------------------------------------------------------------- # CONSUMERS — who reads this secret and how to restart them # --------------------------------------------------------------------------- consumers: type: array required: true minItems: 0 description: | Services that read this secret. The rotator updates each consumer in order and runs the configured healthcheck before continuing. An EMPTY array is valid (e.g., for credentials only the rotator itself uses) but must be empty intentionally — `null` is not allowed. items: type: object required: [ name, kind ] properties: name: type: string description: | Consumer identifier. For docker-compose services, matches the service name. For VM systemd units, matches the unit name. For external endpoints, an arbitrary stable label. kind: type: string enum: - bao-consumer - env-injection - file-mount - external-api description: | How the consumer receives the value: - `bao-consumer`: reads BAO at startup (most common) - `env-injection`: receives the value via an env var written by the deploy pipeline (requires deploy) - `file-mount`: reads the value from a file path written by the rotator (e.g., SSH keys) - `external-api`: a third-party that we PUSH the new value to (e.g., GitHub Actions secrets via API) restart: type: object required: false description: | How to make the consumer pick up the new value. Omit if the consumer reads BAO on every request (no restart needed). properties: kind: type: string enum: - none - docker-restart - systemd-restart - github-actions-rerun - manual description: | Restart mechanism. `manual` means the rotator pauses and prompts the operator; useful for credentials where the restart has cross-cutting impact (e.g., postgres password). service: type: string required: false description: "Service / unit / workflow name passed to the restart kind." healthcheck_url: type: string required: false description: | URL the rotator polls after restart. Must return 2xx within `healthcheck_timeout_seconds` or the rotation is marked `partial-success` and the operator is alerted. healthcheck_timeout_seconds: type: integer required: false minimum: 1 description: "Default 60." update_path: type: string required: false description: | For `kind: file-mount` consumers, the file path the rotator writes the value to. Permissions are set to 0400 owned by the consumer's runtime UID. # --------------------------------------------------------------------------- # AUDIT — classification and notification # --------------------------------------------------------------------------- audit: type: object required: true properties: classification: type: string enum: - platform-critical - platform-standard - tenant-isolated - operational required: true description: | Blast-radius tier: - `platform-critical`: compromise affects all tenants and cannot be recovered without break-glass (e.g., OpenBao root, Cloudflare API token) - `platform-standard`: compromise affects all tenants but is recoverable without break-glass (e.g., SendGrid API key) - `tenant-isolated`: compromise affects a single tenant (e.g., a per-tenant Keycloak client secret) - `operational`: compromise has no security impact, only availability (e.g., observability API keys) tier: type: integer enum: [ 0, 1, 2, 3, 4 ] required: false description: | Rotation tier per `docs/architecture/secret-rotation.md` § "Tier scoping". Drives the `pspace-rotator --tier ` CLI filter so an operator can rotate one tier at a time. - 0: identity gateway (Tailscale, GH PATs, Codespace secrets) - 1: platform infra (Postgres root, Keycloak admin, MinIO root, VM SSH keys, BAO AppRole secret_id) - 2: cloud creds (Cloudflare, Azure SP, AKV access) - 3: application secrets (most things in OpenBao KV) - 4: tenant-scoped secrets (per-possibility) Distinct from `classification` (which is an audit-routing label). Manifests without a tier are excluded from any `--tier N` selection. notify_on_rotation: type: array required: false items: type: string description: | Notification channels for rotation events. SigNoz channels use the `signoz:` scheme (e.g., `signoz:secret-rotation`). Slack channels use `slack:#channel`. Operator pages use `pagerduty:`. notify_on_failure: type: array required: false items: type: string description: | Channels for FAILED rotations. Defaults to `notify_on_rotation` when omitted. Override when failures should escalate higher than successes (almost always the case for `platform-critical`). # ============================================================================= # VALIDATION RULES (beyond JSON schema) # ============================================================================= validation: rules: - id: no-credential-literals level: error description: | No field value may match credential-shaped patterns (high-entropy strings, BEGIN PRIVATE KEY blocks, hex strings >32 chars). Enforced # pragma: allowlist secret by `scripts/lint-secret-manifests.ts`. # pragma: allowlist secret - id: procedure-resolves level: error description: | `rotation.procedure_xri` MUST resolve to an existing `content/rotation-procedures/*.kno` entity at validation time. - id: parent-credential-resolves level: error description: | When set, `rotation.parent_credential_xri` MUST resolve to another `content/secrets/*.kno` entity OR equal the secret's own XRI (self-cycle, see self-cycle-recovery-procedure). - id: no-non-self-cycles level: error description: | The rotator builds the parent_credential_xri dependency graph at startup and rejects any cycle longer than 1 (A → B → A is invalid). Self-cycles (A → A) are explicitly allowed and handled as described in self-cycle-recovery-procedure. - id: self-cycle-recovery-procedure level: warning description: | Secrets where `parent_credential_xri` equals the entity's own XRI SHOULD declare `recovery_procedure_xri`. Without it, `--mode break-glass` rotation has no documented path and the rotator will refuse to run, pointing the operator to a runbook. Warning rather than error so existing manifests can be migrated incrementally. - id: bao-path-lowercase level: warning description: | BAO paths SHOULD be lowercase with `/` separators and `_` inside path segments. Mixed case is permitted (BAO accepts it) but breaks the convention used by the deploy scripts.