#!/usr/bin/env nu # ────────────────────────────────────────────────────────────────────────────── # Registry credential resolution for OCI artifact ops (ADR-017). # Portable copy of ontoref's reflection/modules/secrets.nu — Layer 2 only. # Provisioning never touches Layer 0 (vault access) — that's `ore secrets sync`. # # Contract follows the canonical helper in ontoref/reflection/modules/secrets.nu: # resolve-registry-credential project_root registry_id --op pull|push # → { docker_config_dir, audit_event } # assert-actor-authorized project_root op # → null on success, error on denial # # Caller pattern (used by 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 ... # } # rm -rf $cred.docker_config_dir # # Errors are surfaced via `error make --unspanned` with `[]` prefix: # [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 # ────────────────────────────────────────────────────────────────────────────── use primitives/io/logging.nu [log-debug log-error] # ── 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 _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 _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 _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 ──────────────────────────────────────── export def build-docker-config-tmpdir [ registry: string username: string password: string ]: nothing -> string { let tmpdir = (mktemp -d -t prvng-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 ─────────────────────────────────────────── 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-registry-credential (Layer 2) ───────────────────────────── 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 sync ($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 log-debug $"resolved registry credential — id=($registry_id) op=($op) endpoint=($endpoint)" { docker_config_dir: $tmpdir, audit_event: $event } } # ── Public: assert-actor-authorized ─────────────────────────────────────────── 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 } 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 sync ($vault_id)" } } let scope = (_eval-ncl $scope_path) # bound_actor: when non-empty, only listed actors may assume this role. 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)" } } 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 } # Layer-1 enforcement of scope.namespaces — verify the OCI artifact target # matches one of the role's permitted namespace globs. Caller invokes after # assert-actor-authorized, before any oras call. Layer-2 enforcement (registry # ACL) is the final gate; this is defense in depth. export def assert-target-in-scope [ project_root: string target: string ]: 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 } 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 } 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 }