ontoref/reflection/modules/secrets.nu
Jesús Pérez 82a358f18d
Some checks failed
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (push) Has been cancelled
feat: #[onto_mcp_tool] catalog, OCI credential vault layer, validate ADR-018 mode hierarchy
ontoref-derive: #[onto_mcp_tool] attribute macro registers MCP tool unit-structs in
  the catalog at link time via inventory::submit!; annotated item is emitted unchanged,
  ToolBase/AsyncTool impls stay on the struct. All 34 tools migrated from manual wiring
  (net +5: ontoref_list_projects, ontoref_search, ontoref_describe,
  ontoref_list_ontology_extensions, ontoref_get_ontology_extension).

  validate modes (ADR-018): reads level_hierarchy from workflow.ncl and checks every
  .ncl mode for level declared, strategy declared, delegate chain coherent, compose
  extends valid. mode resolve <id> shows which hierarchy level handles a mode and why.
  --self-test generates synthetic fixtures in a temp dir for CI smoke-testing.

  validate run-cargo: two-step Cargo.toml resolution — workspace layout first
  (crates/<check.crate>/Cargo.toml), single-crate fallback by package name or repo
  basename. Lets the same ADR constraint shape apply to workspace and single-crate repos.

  ontology/schemas/manifest.ncl: registry_topology_type contract — multi-registry
  coordination, push targets, participant scopes, per-namespace capability.

  reflection/requirements/base.ncl: oras ≥1.2.0, cosign ≥2.0.0, sops ≥3.9.0, age
  ≥1.1.0, restic declared as Hard/Soft requirements with version_min, check_cmd, and
  install_hint (ADR-017 toolchain surface).

  ADR-019: per-file recipient routing for tenant isolation without multi-vault. Schema
  additions: sops.recipient_groups + sops.recipient_rules in ontoref-project.ncl.
  secrets-bootstrap generates .sops.yaml from project.ncl in declarative mode. Three
  new secrets-audit checks: recipient-routing-coherent, recipient-routing-coverage,
  no-multi-vault. Adoption templates: single-team/, multi-tenant/, agent-first/.
  Integration templates: domain-producer/, mode-producer/, mode-consumer/.

  UI: project_picker surfaces registry badge (⟳ participant) and vault badge
  (⛁ vault_id · N, green=declarative / amber=legacy) per project card. Expanded panel
  adds collapsible Registry section with namespace, endpoint, and push/pull capability.
  manage.html gains Runtime Services card — MCP and GraphQL toggleable without restart
  via HTMX POST /ui/manage/services/{service}/toggle.

  describe.nu: capabilities JSON includes registry_topology and vault_state per project.
  sync.nu: drift check extended to detect //! absence on newly registered crates.
  qa.ncl: six entries — credential-vault-best-practice (layered data-flow diagram),
  credential-vault-templates (paths A/B/C), credential-vault-troubleshooting (15 named
  errors), integration-what-and-why (ADR-042 OCI federation), integration-how-to-implement,
  integration-troubleshooting.

  on+re: core.ncl + manifest.ncl updated to reflect OCI, MCP, and mode-hierarchy nodes.
  Deleted stale presentation assets (2026-02 slides + voice notes).
2026-05-12 04:46:15 +01:00

711 lines
32 KiB
Text

#!/usr/bin/env nu
# ──────────────────────────────────────────────────────────────────────────────
# Registry credential resolution for src-vault and OCI artifact ops (ADR-017).
#
# Two-layer model — these are different credentials, do not confuse:
#
# ┌─ Layer 0: vault-access ─────────────────────────────────────────────────┐
# │ Where: ~/.config/ontoref/vaults/<vault_id>/access.sops.yaml │
# │ Decrypt: master .kage from project.ncl::sops.master_key_path │
# │ or global ~/.config/ontoref/config.ncl::vault.master_key_path │
# │ Yields: { zot_username, zot_password, vault_key } │
# │ Use for: oras pull src-vault/<project>:latest + restic/kopia open │
# │ Function: resolve-vault-access │
# └─────────────────────────────────────────────────────────────────────────┘
# ┌─ Layer 2: registry-credential ──────────────────────────────────────────┐
# │ Where: ~/.config/ontoref/vaults/<vault_id>/src-vault/ │
# │ <RegistryEntry.credential_sops[_rw]> │
# │ (multi-recipient encrypted file inside the synced src-vault — │
# │ paths in the manifest are relative to the src-vault root) │
# │ Decrypt: same master .kage as Layer 0 │
# │ Yields: { docker_config_dir, audit_event } │
# │ Use for: oras push|pull domains/<participant>/<id>:<ver> │
# │ Function: resolve-registry-credential │
# └─────────────────────────────────────────────────────────────────────────┘
#
# Authorization gate (assert-actor-authorized) is independent: validates the
# ONTOREF_ACTOR → role binding + scope.ops before either function is called.
# Callers MUST gate first, resolve second.
#
# Canonical caller pattern (e.g. domain_client.nu):
#
# assert-actor-authorized $project_root push
# let cred = (resolve-registry-credential $project_root primary --op push)
# with-env { DOCKER_CONFIG: $cred.docker_config_dir } {
# ^oras push $ref --artifact-type ...
# }
# rm -rf $cred.docker_config_dir
#
# Errors are surfaced via `error make --unspanned` with a `[<code>]` prefix in
# the message. Catchable via `try { ... } catch { |e| ... }`. Codes:
#
# [actor-bindings-missing] project.ncl::sops.actor_key_bindings empty
# [actor-not-bound] ONTOREF_ACTOR has no entry in actor_key_bindings
# [actor-not-in-bound-actor] scope.bound_actor non-empty and excludes the actor
# [credential-sops-missing] RegistryEntry has no credential_sops/_rw for op
# [invalid-op] op is neither 'pull' nor 'push'
# [kage-not-resolvable] master_key_path absent or file missing
# [manifest-ncl-missing] <project>/.ontology/manifest.ncl absent
# [op-not-in-scope] scope.ops does not include the requested op
# [project-ncl-missing] <project>/.ontoref/project.ncl absent
# [registry-id-unknown] registry_id not found in registries[]
# [registry-provides-missing] manifest has no registry_provides
# [scope-not-loaded] src-vault not opened — scopes/<role>.ncl missing
# [sops-decrypt-failed] sops returned non-zero
# [sops-file-not-found] credential_sops path does not exist on disk
# [target-not-in-scope] target ref does not match scope.namespaces globs
#
# Daemon never calls these functions — daemon has no .kage, by structural rule
# (ADR-017 invariant: "Daemon: solo declarativo — nunca toca credenciales").
# ──────────────────────────────────────────────────────────────────────────────
# ── Internal helpers ──────────────────────────────────────────────────────────
def _project-ncl [project_root: string]: nothing -> string {
let p = ($project_root | path join ".ontoref" "project.ncl")
if not ($p | path exists) {
error make --unspanned { msg: $"[project-ncl-missing] No project.ncl at ($p)" }
}
$p
}
def _manifest-ncl [project_root: string]: nothing -> string {
let p = ($project_root | path join ".ontology" "manifest.ncl")
if not ($p | path exists) {
error make --unspanned { msg: $"[manifest-ncl-missing] No manifest.ncl at ($p)" }
}
$p
}
def _import-path []: nothing -> string {
$env.NICKEL_IMPORT_PATH? | default ($env.PROVISIONING? | default "")
}
def _eval-ncl [path: string]: nothing -> any {
let r = (do { ^nickel export --import-path (_import-path) $path } | complete)
if $r.exit_code != 0 {
error make --unspanned { msg: $"Failed to evaluate ($path):\n($r.stderr)" }
}
$r.stdout | from json
}
def _resolve-master-key-path [project_sops: record]: nothing -> string {
let proj_override = ($project_sops | get -o master_key_path | default "")
if ($proj_override | is-not-empty) {
if not ($proj_override | path exists) {
error make --unspanned {
msg: $"[kage-not-resolvable] project.ncl::sops.master_key_path = ($proj_override) — file missing"
}
}
return $proj_override
}
let global_path = ($env.HOME | path join ".config" "ontoref" "config.ncl")
if not ($global_path | path exists) {
error make --unspanned {
msg: "[kage-not-resolvable] master_key_path absent in project.ncl and ~/.config/ontoref/config.ncl missing"
}
}
let global = (_eval-ncl $global_path)
let global_kage = ($global | get -o vault.master_key_path | default "")
if ($global_kage | is-empty) {
error make --unspanned {
msg: "[kage-not-resolvable] master_key_path absent both in project.ncl::sops and ~/.config/ontoref/config.ncl::vault"
}
}
if not ($global_kage | path exists) {
error make --unspanned {
msg: $"[kage-not-resolvable] global master_key_path = ($global_kage) — file missing"
}
}
$global_kage
}
def _vault-id [project: record]: nothing -> string {
let from_sops = ($project | get -o sops.vault_id | default "")
if ($from_sops | is-not-empty) { return $from_sops }
let slug = ($project | get -o slug | default "")
if ($slug | is-empty) {
error make --unspanned {
msg: "sops.vault_id not declared and project slug absent"
}
}
$slug
}
def _select-credential-field [op: string]: nothing -> string {
match $op {
"pull" => "credential_sops",
"push" => "credential_sops_rw",
_ => {
error make --unspanned { msg: $"[invalid-op] op must be 'pull' or 'push', got: ($op)" }
}
}
}
# ── Public: build-docker-config-tmpdir ────────────────────────────────────────
# Allocates an isolated DOCKER_CONFIG directory with a single registry credential.
# Caller must `rm -rf` the returned path immediately after the oras invocation.
export def build-docker-config-tmpdir [
registry: string
username: string
password: string
]: nothing -> string {
let tmpdir = (mktemp -d -t ontoref-dockercfg.XXXXXX)
let auth_b64 = ($"($username):($password)" | encode base64 | str trim)
$'{"auths":{"($registry)":{"auth":"($auth_b64)"}}}'
| save ($tmpdir | path join "config.json")
$tmpdir
}
# ── Public: append-vault-access-log ───────────────────────────────────────────
# Append a structured entry to ~/.config/ontoref/vaults/<vault_id>/logs/access.jsonl.
export def append-vault-access-log [
vault_id: string
actor: string
op: string
--detail: string = ""
--registry-id: string = ""
--sops-path: string = ""
]: nothing -> nothing {
let logs_dir = ($env.HOME | path join ".config" "ontoref" "vaults" $vault_id "logs")
mkdir $logs_dir
let entry = {
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
actor: $actor,
op: $op,
registry_id: $registry_id,
sops_path: $sops_path,
detail: $detail,
}
(($entry | to json --raw) ++ "\n")
| save --append ($logs_dir | path join "access.jsonl")
}
# ── Public: resolve-vault-access (Layer 0) ────────────────────────────────────
# Reads ~/.config/ontoref/vaults/<vault_id>/access.sops.yaml and returns the
# credentials needed to pull src-vault/<vault_id>:latest from ZOT and to open
# the local restic/kopia repo. Decrypt happens in-memory; nothing is written.
export def resolve-vault-access [
project_root: string
]: nothing -> record {
let project = (_eval-ncl (_project-ncl $project_root))
let sops_cfg = ($project | get -o sops | default null)
if $sops_cfg == null or not ($sops_cfg | get -o enabled | default false) {
return { zot_username: "", zot_password: "", vault_key: "" }
}
let vault_id = (_vault-id $project)
let access_sops = ($env.HOME | path join ".config" "ontoref" "vaults" $vault_id "access.sops.yaml")
if not ($access_sops | path exists) {
error make --unspanned {
msg: $"[scope-not-loaded] access.sops.yaml not found at ($access_sops) — run: ore secrets open ($vault_id)"
}
}
let kage = (_resolve-master-key-path $sops_cfg)
let r = (with-env { SOPS_AGE_KEY_FILE: $kage } {
do { ^sops --decrypt $access_sops } | complete
})
if $r.exit_code != 0 {
error make --unspanned {
msg: $"[sops-decrypt-failed] Layer 0 access.sops.yaml decrypt failed:\n($r.stderr)"
}
}
let creds = ($r.stdout | from yaml)
{
zot_username: ($creds | get -o zot_username | default ""),
zot_password: ($creds | get -o zot_password | default ""),
vault_key: ($creds | get -o vault_key | default ""),
}
}
# ── Public: resolve-registry-credential (Layer 2) ─────────────────────────────
# Looks up RegistryEntry by id in manifest.registry_provides.registries.registries,
# selects credential_sops (op=pull) or credential_sops_rw (op=push), decrypts it
# with the master .kage, and returns an isolated DOCKER_CONFIG tmpdir.
# Caller MUST rm -rf the returned docker_config_dir after the oras call.
#
# This function does NOT validate actor authorization — call assert-actor-authorized
# beforehand. It is a no-op to call this function as agent or ci if scope allows.
export def resolve-registry-credential [
project_root: string
registry_id: string # "" = use registries.default from manifest
--op: string = "pull"
]: nothing -> record {
let field = (_select-credential-field $op)
let manifest = (_eval-ncl (_manifest-ncl $project_root))
let provides = ($manifest | get -o registry_provides | default null)
if $provides == null {
error make --unspanned {
msg: $"[registry-provides-missing] .ontology/manifest.ncl has no registry_provides block"
}
}
let entries = ($provides | get -o registries.registries | default [])
let actual_id = if ($registry_id | is-empty) {
let from_manifest = ($provides | get -o registries.default | default "")
if ($from_manifest | is-empty) {
error make --unspanned {
msg: $"[registry-id-unknown] registry_id empty and registries.default not declared in manifest"
}
}
$from_manifest
} else { $registry_id }
let entry = ($entries | where { |e| $e.id == $actual_id } | first | default null)
if $entry == null {
let known = ($entries | each { |e| $e.id } | str join ", ")
error make --unspanned {
msg: $"[registry-id-unknown] no RegistryEntry with id=($actual_id). Known: [($known)]"
}
}
let sops_rel = ($entry | get -o $field | default "")
if ($sops_rel | is-empty) {
error make --unspanned {
msg: $"[credential-sops-missing] RegistryEntry id=($registry_id) has no ($field) for op=($op)"
}
}
let project = (_eval-ncl (_project-ncl $project_root))
let sops_cfg = ($project | get -o sops | default {})
let vault_id = (_vault-id $project)
let vault_root = ($env.HOME | path join ".config" "ontoref" "vaults" $vault_id "src-vault")
let sops_path = ($vault_root | path join $sops_rel)
if not ($sops_path | path exists) {
let hint = $"vault may not be opened — try: ore secrets open ($vault_id)"
error make --unspanned {
msg: $"[sops-file-not-found] ($sops_path) — ($hint)"
}
}
let kage = (_resolve-master-key-path $sops_cfg)
let r = (with-env { SOPS_AGE_KEY_FILE: $kage } {
do { ^sops --decrypt --output-type json $sops_path } | complete
})
if $r.exit_code != 0 {
error make --unspanned {
msg: $"[sops-decrypt-failed] ($sops_path) decrypt failed:\n($r.stderr)"
}
}
let creds = ($r.stdout | from json)
let user = ($creds | get -o username | default "")
let pass = ($creds | get -o password | default "")
let endpoint = ($entry.endpoint)
let tmpdir = (build-docker-config-tmpdir $endpoint $user $pass)
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let event = {
ts: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
actor: $actor,
op: $op,
registry_id: $actual_id,
endpoint: $endpoint,
sops_path: $sops_rel,
}
append-vault-access-log $vault_id $actor $op --registry-id $actual_id --sops-path $sops_rel
{ docker_config_dir: $tmpdir, audit_event: $event }
}
# ── Public: assert-actor-authorized ───────────────────────────────────────────
# Reads project.ncl::sops.actor_key_bindings to map ONTOREF_ACTOR → role, then
# loads ~/.config/ontoref/vaults/<vault_id>/src-vault/scopes/<role>.ncl and
# verifies the requested op is in scope.ops. Errors are nominal so callers can
# distinguish authorization failures from credential-resolution failures.
export def assert-actor-authorized [
project_root: string
op: string
]: nothing -> nothing {
let _ = (_select-credential-field $op) # validates op early
let project = (_eval-ncl (_project-ncl $project_root))
let sops_cfg = ($project | get -o sops | default null)
if $sops_cfg == null or not ($sops_cfg | get -o enabled | default false) {
return null # sops disabled = no enforcement (consistent with resolve-vault-access)
}
let bindings = ($sops_cfg | get -o actor_key_bindings | default {})
if ($bindings | is-empty) {
error make --unspanned {
msg: "[actor-bindings-missing] project.ncl::sops.actor_key_bindings is empty"
}
}
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let role = ($bindings | get -o $actor | default "")
if ($role | is-empty) {
let known = ($bindings | columns | str join ", ")
error make --unspanned {
msg: $"[actor-not-bound] ONTOREF_ACTOR=($actor) has no entry in actor_key_bindings. Known actors: [($known)]"
}
}
let vault_id = (_vault-id $project)
let scope_path = ($env.HOME
| path join ".config" "ontoref" "vaults" $vault_id "src-vault" "scopes" $"($role).ncl")
if not ($scope_path | path exists) {
error make --unspanned {
msg: $"[scope-not-loaded] ($scope_path) missing — vault not opened. Run: ore secrets open ($vault_id)"
}
}
let scope = (_eval-ncl $scope_path)
# bound_actor: when non-empty, only listed actors may assume this role.
# Empty list = no restriction (the actor_key_bindings mapping is the only gate).
let bound = ($scope | get -o bound_actor | default [])
if ($bound | is-not-empty) and ($actor not-in $bound) {
error make --unspanned {
msg: $"[actor-not-in-bound-actor] role=($role) bound_actor=($bound) does not include ONTOREF_ACTOR=($actor)"
}
}
# ops: the operation must be permitted by the role.
let ops = ($scope | get -o ops | default [])
let allowed = ($ops | any { |o|
let s = ($o | describe)
if $s == "string" { $o == $op } else { ($o | to text) == $op }
})
if not $allowed {
error make --unspanned {
msg: $"[op-not-in-scope] role=($role) does not allow op=($op). scope.ops=($ops)"
}
}
null
}
# ── Public: assert-target-in-scope ────────────────────────────────────────────
# Validate that an OCI artifact target (e.g. "domains/libre-wuji/zot") is
# permitted by the current actor's scope.namespaces glob list. Caller must invoke
# this AFTER assert-actor-authorized and BEFORE the oras call. Defense-in-depth
# layer-1 (CLI pre-validation) per ADR-017 — registry ACL is the layer-2 enforcement.
export def assert-target-in-scope [
project_root: string
target: string # e.g. "domains/libre-wuji/zot" or "modes/foo/bar"
]: nothing -> nothing {
let project = (_eval-ncl (_project-ncl $project_root))
let sops_cfg = ($project | get -o sops | default null)
if $sops_cfg == null or not ($sops_cfg | get -o enabled | default false) {
return null
}
let bindings = ($sops_cfg | get -o actor_key_bindings | default {})
let actor = ($env.ONTOREF_ACTOR? | default "developer")
let role = ($bindings | get -o $actor | default "")
if ($role | is-empty) { return null } # assert-actor-authorized errors first
let vault_id = (_vault-id $project)
let scope_path = ($env.HOME
| path join ".config" "ontoref" "vaults" $vault_id "src-vault" "scopes" $"($role).ncl")
if not ($scope_path | path exists) { return null }
let scope = (_eval-ncl $scope_path)
let namespaces = ($scope | get -o namespaces | default [])
if ($namespaces | is-empty) { return null } # no restrictions declared
# Each entry like "domains/*/" or "modes/libre-wuji/" — match by prefix-after-glob.
let matched = ($namespaces | any { |ns|
let normalized = ($ns | str trim --right --char "/")
if ($normalized | str contains "*") {
let prefix = ($normalized | str replace --regex '\*.*' '')
$target | str starts-with $prefix
} else {
$target | str starts-with $normalized
}
})
if not $matched {
error make --unspanned {
msg: $"[target-not-in-scope] role=($role) namespaces=($namespaces) does not allow target=($target)"
}
}
null
}
# ── CLI dispatch — `ore secrets X` ────────────────────────────────────────────
# Each command shells out to the corresponding bash recipe in justfiles/secrets.just,
# propagating ONTOREF_PROJECT_ROOT so the recipe targets the consumer project, not
# ontoref's own checkout. The recipes carry the reference orchestration logic
# (project.ncl read, sops cipher/decipher, oras + cosign + restic invocation).
def _project-root-cli []: nothing -> string {
let from_env = ($env.ONTOREF_PROJECT_ROOT? | default "")
if ($from_env | is-not-empty) { return $from_env }
pwd
}
def _run-secrets-recipe [recipe: string, ...args: string]: nothing -> nothing {
let ontoref_root = ($env.ONTOREF_ROOT? | default "")
if ($ontoref_root | is-empty) {
error make --unspanned {
msg: "ONTOREF_ROOT not set — invoke via ./ontoref entry point, not nu directly"
}
}
let recipe_file = ($ontoref_root | path join "justfiles" "secrets.just")
if not ($recipe_file | path exists) {
error make --unspanned { msg: $"secrets.just not found at ($recipe_file)" }
}
let project_root = (_project-root-cli)
with-env { ONTOREF_PROJECT_ROOT: $project_root } {
^just -f $recipe_file $recipe ...$args
}
}
# Bootstrap a new vault for the active project (admin only). Creates access.sops.yaml,
# initializes the local restic/kopia repo. Push to ZOT happens via `secrets push`.
export def "secrets bootstrap" []: nothing -> nothing {
_run-secrets-recipe "secrets-bootstrap"
}
# Pull latest src-vault/<vault_id>:latest from ZOT and restore locally.
export def "secrets sync" []: nothing -> nothing {
_run-secrets-recipe "secrets-sync"
}
# Push current vault state to ZOT — signs with cosign (mandatory per ADR-017).
export def "secrets push" []: nothing -> nothing {
_run-secrets-recipe "secrets-push"
}
# Open the vault for editing — runs sops on access.sops.yaml.
export def "secrets open" []: nothing -> nothing {
_run-secrets-recipe "secrets-open"
}
# Close the vault — pushes updated state and releases the edit context.
export def "secrets close" []: nothing -> nothing {
_run-secrets-recipe "secrets-close"
}
# Force-release an abandoned or expired lock (admin operation, audited).
export def "secrets force-unlock" []: nothing -> nothing {
_run-secrets-recipe "secrets-force-unlock"
}
# Add or update cosign_password inside access.sops.yaml without opening the
# editor. Use to upgrade vaults bootstrapped before the cosign_password field
# existed, so 'secrets push' runs non-interactively.
export def "secrets set-cosign-password" []: nothing -> nothing {
_run-secrets-recipe "secrets-set-cosign-password"
}
# Recover a corrupted/missing access.sops.yaml from the ZOT artifact.
# Caller provides credentials via RECOVERY_ZOT_USER + RECOVERY_ZOT_PASS env
# vars or a pre-built DOCKER_CONFIG. See FAQ::credential-vault-disaster-recovery.
export def "secrets recover" []: nothing -> nothing {
_run-secrets-recipe "secrets-recover"
}
# Add a recipient age public key to access.sops.yaml.
# Use after a new developer/role is added to the project.
export def "secrets add-key" [pubkey: string, role: string]: nothing -> nothing {
_run-secrets-recipe "secrets-add-key" $pubkey $role
}
# Remove a recipient age public key (revocation). The token in the registry should
# be rotated separately — sops removal does not invalidate already-known plaintext.
export def "secrets remove-key" [pubkey: string]: nothing -> nothing {
_run-secrets-recipe "secrets-remove-key" $pubkey
}
# Re-encrypt all *.sops.yaml in the vault with the current .sops.yaml recipient list.
export def "secrets rekey" []: nothing -> nothing {
_run-secrets-recipe "secrets-rekey"
}
# Generate a new age keypair for a role (prints private key to stdout — never stored on disk).
export def "secrets gen-key" [role: string]: nothing -> nothing {
_run-secrets-recipe "secrets-gen-key" $role
}
# Run vault constraints audit. Default: all checks. Specific:
# bootstrap-credentials decrypts access.sops.yaml with master_key
# no-credential-env manifest does not declare credential_env (banned by ADR-017)
# recipients access.sops.yaml has ≥ 2 recipients (multi-recipient mandatory)
export def "secrets audit" [--check: string = ""]: nothing -> nothing {
if ($check | is-empty) {
_run-secrets-recipe "secrets-audit"
} else {
_run-secrets-recipe "secrets-audit" $"check=($check)"
}
}
# Show vault state for the active project — declared sops config, vault dir presence,
# decrypt sanity, last access log entry.
export def "secrets status" []: nothing -> nothing {
let project_root = (_project-root-cli)
let project_ncl = ($project_root | path join ".ontoref" "project.ncl")
if not ($project_ncl | path exists) {
print $" (ansi yellow)No .ontoref/project.ncl at ($project_root)(ansi reset)"
return
}
let project = (_eval-ncl $project_ncl)
let sops_cfg = ($project | get -o sops | default null)
if $sops_cfg == null or not ($sops_cfg | get -o enabled | default false) {
print " sops: disabled in project.ncl"
return
}
let vault_id = (_vault-id $project)
let vault_dir = ($env.HOME | path join ".config" "ontoref" "vaults" $vault_id)
let access_sops = ($vault_dir | path join "access.sops.yaml")
let logs_jsonl = ($vault_dir | path join "logs" "access.jsonl")
print $" (ansi white_bold)Vault(ansi reset) ($vault_id)"
print $" (ansi dark_gray)backend(ansi reset) ($sops_cfg | get -o vault_backend | default 'restic')"
print $" (ansi dark_gray)vault dir(ansi reset) ($vault_dir)"
print $" (ansi dark_gray)access.sops(ansi reset) (if ($access_sops | path exists) { 'present' } else { 'MISSING — run: ore secrets sync' })"
print $" (ansi dark_gray)master_key(ansi reset) (try { _resolve-master-key-path $sops_cfg } catch { |e| $e.msg })"
# Cosign keypair state — read from global config, no env-var fallback (ADR-017 declarative)
let global_cfg = ($env.HOME | path join ".config" "ontoref" "config.ncl")
let cosign = if ($global_cfg | path exists) {
try { _eval-ncl $global_cfg | get -o vault.cosign | default {} } catch { {} }
} else { {} }
let key_path = ($cosign | get -o key_path | default "")
let pub_path = ($cosign | get -o pub_path | default "")
let tlog = ($cosign | get -o tlog | default false)
let key_state = if ($key_path | is-empty) {
"(ansi yellow)not declared(ansi reset)"
} else if ($key_path | path exists) { "present" } else { $"(ansi red)missing(ansi reset) — ($key_path)" }
let pub_state = if ($pub_path | is-empty) {
"(ansi yellow)not declared(ansi reset)"
} else if ($pub_path | path exists) { "present" } else { $"(ansi red)missing(ansi reset) — ($pub_path)" }
let tlog_state = if $tlog { "(ansi cyan)Rekor public log(ansi reset)" } else { "private (no Rekor)" }
print $" (ansi dark_gray)cosign key(ansi reset) ($key_state)"
print $" (ansi dark_gray)cosign pub(ansi reset) ($pub_state)"
print $" (ansi dark_gray)cosign tlog(ansi reset) ($tlog_state)"
if ($logs_jsonl | path exists) {
let last = (open --raw $logs_jsonl | lines | last | from json)
let reg = ($last | get -o registry_id | default "")
let extra = if ($reg | is-empty) { "" } else { $" — registry=($reg)" }
print $" (ansi dark_gray)last op(ansi reset) ($last.op) by ($last.actor) @ ($last.ts)($extra)"
} else {
print " last op (no entries)"
}
}
# Detailed inventory of the active project's vault: recipients, roles, declared
# registry credentials, last operations. Read-only; no decrypt of sensitive values.
export def "secrets describe" []: nothing -> nothing {
let project_root = (_project-root-cli)
let project_ncl = ($project_root | path join ".ontoref" "project.ncl")
if not ($project_ncl | path exists) {
print $" (ansi yellow)No .ontoref/project.ncl at ($project_root)(ansi reset)"
return
}
let project = (_eval-ncl $project_ncl)
let sops_cfg = ($project | get -o sops | default null)
if $sops_cfg == null or not ($sops_cfg | get -o enabled | default false) {
print " sops: disabled in project.ncl"
return
}
let vault_id = (_vault-id $project)
let vault_dir = ($env.HOME | path join ".config" "ontoref" "vaults" $vault_id)
let src_vault = ($vault_dir | path join "src-vault")
print ""
print $"(ansi white_bold)Vault(ansi reset) ($vault_id)"
print $" project_root ($project_root)"
print $" vault_dir ($vault_dir)"
print $" backend ($sops_cfg | get -o vault_backend | default 'restic')"
print $" endpoint ($sops_cfg | get -o registry_endpoint | default '(not declared)')"
print ""
# Per-file recipient routing (declarative model, when present).
let groups = ($sops_cfg | get -o recipient_groups | default {})
let rules = ($sops_cfg | get -o recipient_rules | default [])
if ($rules | is-not-empty) {
print $" (ansi white_bold)Recipient groups(ansi reset)"
for g in ($groups | columns) {
let keys = ($groups | get $g)
let count = ($keys | length)
print $" ($g) ($count) key\(s\)"
for k in $keys {
print $" ($k)"
}
}
print ""
print $" (ansi white_bold)Path rules(ansi reset) ($rules | length) total"
for r in $rules {
let merged = ($r.groups | each { |g| $groups | get -o $g | default [] } | flatten | uniq)
let n = ($merged | length)
let glist = ($r.groups | str join " + ")
print $" ($r.path)"
print $" groups: ($glist) → ($n) recipient\(s\)"
}
print ""
} else {
# Legacy mode — count age1... in access.sops.yaml metadata.
let access_sops = ($vault_dir | path join "access.sops.yaml")
if ($access_sops | path exists) {
let recipients = (open --raw $access_sops | from yaml
| get -o sops.age | default []
| each { |r| $r.recipient? | default "" }
| where { |r| $r | str starts-with "age1" })
let nrec = ($recipients | length)
print $" (ansi white_bold)Recipients(ansi reset) ($nrec) — legacy single-set mode"
for r in $recipients {
print $" ($r)"
}
} else {
print $" (ansi yellow)access.sops.yaml absent(ansi reset) — run: ore secrets sync"
}
print ""
}
# actor_key_bindings table.
let bindings = ($sops_cfg | get -o actor_key_bindings | default {})
if ($bindings | columns | is-not-empty) {
print $" (ansi white_bold)Actor → role(ansi reset)"
for actor in ($bindings | columns) {
let role = ($bindings | get $actor)
print $" ($actor) → ($role)"
}
print ""
}
# Roles (scopes/) with their access mode.
if ($src_vault | path join "scopes" | path exists) {
let scope_files = (ls ($src_vault | path join "scopes") | where name =~ '\.ncl$' | get name)
let nfiles = ($scope_files | length)
print $" (ansi white_bold)Roles(ansi reset) ($nfiles) scope file\(s\)"
for sf in $scope_files {
let role = ($sf | path basename | str replace ".ncl" "")
let s = (try { _eval-ncl $sf } catch { {} })
let access = ($s | get -o access | default "(none)")
let ops = ($s | get -o ops | default [] | str join ", ")
let bound = ($s | get -o bound_actor | default [] | str join ", ")
let bound_disp = if ($bound | is-empty) { "(unbound)" } else { $bound }
print $" ($role) access=($access) bound=($bound_disp)"
print $" ops: ($ops)"
}
print ""
} else {
print $" (ansi yellow)scopes/ absent(ansi reset) — vault not bootstrapped or not synced"
print ""
}
# Registry credential files.
if ($src_vault | path join "registry" | path exists) {
let creds = (ls ($src_vault | path join "registry") | where name =~ '\.sops\.yaml$' | get name)
print $" (ansi white_bold)Registry credentials(ansi reset) ($creds | length)"
for c in $creds {
print $" ($c | path basename)"
}
print ""
}
# Recent operations from access.jsonl.
let logs_jsonl = ($vault_dir | path join "logs" "access.jsonl")
if ($logs_jsonl | path exists) {
let entries = (open --raw $logs_jsonl | lines | last 5 | each { |l| $l | from json })
print $" (ansi white_bold)Last operations(ansi reset) ($entries | length) most recent"
for e in $entries {
let reg = ($e | get -o registry_id | default "")
let r = if ($reg | is-empty) { "" } else { $" registry=($reg)" }
print $" ($e.ts) ($e.actor) ($e.op)($r)"
}
print ""
}
}