#!/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//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/:latest + restic/kopia open │ # │ Function: resolve-vault-access │ # └─────────────────────────────────────────────────────────────────────────┘ # ┌─ Layer 2: registry-credential ──────────────────────────────────────────┐ # │ Where: ~/.config/ontoref/vaults//src-vault/ │ # │ │ # │ (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//: │ # │ 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 `[]` 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] /.ontology/manifest.ncl absent # [op-not-in-scope] scope.ops does not include the requested op # [project-ncl-missing] /.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/.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//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//access.sops.yaml and returns the # credentials needed to pull src-vault/: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//src-vault/scopes/.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/: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 "" } }