349 lines
14 KiB
Text
349 lines
14 KiB
Text
|
|
#!/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 `[<code>]` 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] <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
|
||
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|