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).
711 lines
32 KiB
Text
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 ""
|
|
}
|
|
}
|