provisioning-core/nulib/platform/oci/credentials.nu

349 lines
14 KiB
Text
Raw Permalink Normal View History

#!/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
}