771 lines
38 KiB
Text
771 lines
38 KiB
Text
|
|
# Secrets Module — src-vault credential management (ADR-017)
|
||
|
|
# =============================================================
|
||
|
|
# All operations read credentials from ~/.config/ontoref/vaults/<vault_id>/access.sops.yaml,
|
||
|
|
# decrypted by the .kage master key resolved from project.ncl::sops.master_key_path
|
||
|
|
# (per-project override) or ~/.config/ontoref/config.ncl::vault.master_key_path (global).
|
||
|
|
# Cosign keypair for vault signing/verification lives in ~/.config/ontoref/config.ncl::vault.cosign.
|
||
|
|
# Direct restic/kopia or sops invocations bypass lock and audit — use these recipes instead.
|
||
|
|
#
|
||
|
|
# Common variable loading lives in justfiles/_secrets_lib.sh. Each recipe sources it via
|
||
|
|
# `{{source_directory()}}/_secrets_lib.sh` to get a consistent, validated env.
|
||
|
|
|
||
|
|
# Generate a new age keypair for a role. Prints public + private to stdout.
|
||
|
|
secrets-gen-key role:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
set -euo pipefail
|
||
|
|
which age-keygen > /dev/null || { echo "age-keygen not found — install age"; exit 1; }
|
||
|
|
echo "Generating age keypair for role: {{role}}"
|
||
|
|
age-keygen
|
||
|
|
|
||
|
|
# Bootstrap the vault for a new project. Admin-only.
|
||
|
|
# Collects all recipient public keys, creates access.sops.yaml encrypted for all recipients,
|
||
|
|
# initializes the local restic/kopia repo. Run secrets-push afterward to upload to ZOT.
|
||
|
|
#
|
||
|
|
# Prerequisites:
|
||
|
|
# - All recipient age public keys collected (one per role)
|
||
|
|
# - Admin .kage available at master_key_path
|
||
|
|
# - SOPS_AGE_RECIPIENTS set to comma-separated list of all recipient age public keys
|
||
|
|
secrets-bootstrap:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
mkdir -p "$VAULT_DIR/logs"
|
||
|
|
|
||
|
|
# Per-file recipient routing — when project declares recipient_rules, generate
|
||
|
|
# <vault_dir>/.sops.yaml so sops uses different recipient unions per file path.
|
||
|
|
# Otherwise, fall back to SOPS_AGE_RECIPIENTS env var (legacy single-set mode).
|
||
|
|
RULES_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
|
||
|
|
GROUPS_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
|
||
|
|
HAS_RULES=$(echo "$RULES_JSON" | jq 'length > 0')
|
||
|
|
SOPS_CFG_PATH="$VAULT_DIR/.sops.yaml"
|
||
|
|
|
||
|
|
if [[ "$HAS_RULES" == "true" ]]; then
|
||
|
|
# ADR-019 coherency check — every group referenced by a rule MUST exist
|
||
|
|
# as a key in recipient_groups, AND every recipient_group MUST resolve to
|
||
|
|
# at least one non-empty age public key. A typo or empty group produces
|
||
|
|
# an unreadable .sops.yaml — better to fail loud at bootstrap.
|
||
|
|
UNKNOWN_GROUPS=$(echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
|
||
|
|
[.[] | .groups[] | . as $g | select($groups | has($g) | not)] | unique | join(", ")
|
||
|
|
')
|
||
|
|
if [[ -n "$UNKNOWN_GROUPS" ]]; then
|
||
|
|
echo "[recipient-rules] groups referenced in recipient_rules but missing from recipient_groups: $UNKNOWN_GROUPS" >&2
|
||
|
|
echo " → declare them in project.ncl::sops.recipient_groups, or fix the typo in recipient_rules[].groups" >&2
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
EMPTY_GROUPS=$(echo "$GROUPS_JSON" | jq -r '
|
||
|
|
to_entries | map(select(.value | length == 0) | .key) | join(", ")
|
||
|
|
')
|
||
|
|
if [[ -n "$EMPTY_GROUPS" ]]; then
|
||
|
|
echo "[recipient-rules] recipient_groups with no age keys: $EMPTY_GROUPS" >&2
|
||
|
|
echo " → populate them in project.ncl::sops.recipient_groups, or remove the unused group(s)" >&2
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
UNREFERENCED_GROUPS=$(jq -nrc \
|
||
|
|
--argjson rules "$RULES_JSON" --argjson groups "$GROUPS_JSON" '
|
||
|
|
($groups | keys) - ($rules | map(.groups[]) | unique) | join(", ")
|
||
|
|
')
|
||
|
|
if [[ -n "$UNREFERENCED_GROUPS" ]]; then
|
||
|
|
echo "[recipient-rules] WARN: recipient_groups declared but never used by any rule: $UNREFERENCED_GROUPS" >&2
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Resolve groups → age list per rule, write .sops.yaml
|
||
|
|
echo "creation_rules:" > "$SOPS_CFG_PATH"
|
||
|
|
# Use YAML single-quoted strings for path_regex so backslash escapes
|
||
|
|
# like \\. pass through to sops without YAML interpreting them.
|
||
|
|
echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
|
||
|
|
.[] | " - path_regex: '"'"'\(.path)'"'"'\n age: " +
|
||
|
|
([.groups[] | $groups[.] // []] | flatten | unique | join(","))
|
||
|
|
' >> "$SOPS_CFG_PATH"
|
||
|
|
echo "Generated $SOPS_CFG_PATH with $(echo "$RULES_JSON" | jq length) rule(s)."
|
||
|
|
# When sops sees --config, it uses it; we don't need --age flag.
|
||
|
|
SOPS_ENC_FLAGS=(--config "$SOPS_CFG_PATH")
|
||
|
|
else
|
||
|
|
[[ -n "${SOPS_AGE_RECIPIENTS:-}" ]] || {
|
||
|
|
echo "SOPS_AGE_RECIPIENTS not set — export age public keys, OR declare sops.recipient_groups+rules in project.ncl"
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
SOPS_ENC_FLAGS=(--age "$SOPS_AGE_RECIPIENTS")
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [[ -f "$ACCESS_SOPS" ]]; then
|
||
|
|
echo "access.sops.yaml already exists at $ACCESS_SOPS — skipping creation"
|
||
|
|
else
|
||
|
|
echo "Creating access.sops.yaml. Required inputs:"
|
||
|
|
echo " zot_username + zot_password : ZOT registry credentials for src-vault/<id> namespace"
|
||
|
|
echo " vault_key : restic/kopia repo password"
|
||
|
|
echo " cosign_password : password for vault.cosign.key_path (used by 'secrets push' to sign)"
|
||
|
|
echo
|
||
|
|
read -r -p "zot_username: " ZOT_USER
|
||
|
|
read -r -s -p "zot_password: " ZOT_PASS; echo
|
||
|
|
read -r -s -p "vault_key (restic/kopia password): " VAULT_KEY; echo
|
||
|
|
read -r -s -p "cosign_password (for cosign.key): " COSIGN_PWD; echo
|
||
|
|
|
||
|
|
printf 'zot_username: %s\nzot_password: %s\nvault_key: %s\ncosign_password: %s\n' \
|
||
|
|
"$ZOT_USER" "$ZOT_PASS" "$VAULT_KEY" "$COSIGN_PWD" \
|
||
|
|
| SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "${SOPS_ENC_FLAGS[@]}" \
|
||
|
|
--filename-override "$ACCESS_SOPS" \
|
||
|
|
--input-type yaml --encrypt /dev/stdin > "$ACCESS_SOPS"
|
||
|
|
unset ZOT_USER ZOT_PASS VAULT_KEY COSIGN_PWD
|
||
|
|
echo "access.sops.yaml created and encrypted for all recipients"
|
||
|
|
fi
|
||
|
|
|
||
|
|
REPO_PATH="$VAULT_DIR/repo"
|
||
|
|
if [[ -d "$REPO_PATH" ]]; then
|
||
|
|
echo "Vault repo already exists at $REPO_PATH — skipping init"
|
||
|
|
else
|
||
|
|
CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
|
||
|
|
VAULT_KEY_VAL=$(echo "$CREDS" | grep vault_key | awk '{print $2}')
|
||
|
|
case "$VAULT_TOOL" in
|
||
|
|
restic) RESTIC_PASSWORD="$VAULT_KEY_VAL" restic -r "$REPO_PATH" init ;;
|
||
|
|
kopia) KOPIA_PASSWORD="$VAULT_KEY_VAL" kopia repository create filesystem --path "$REPO_PATH" ;;
|
||
|
|
esac
|
||
|
|
unset VAULT_KEY_VAL CREDS
|
||
|
|
echo "Vault repo initialized at $REPO_PATH"
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Initial audit entry — ensures access.jsonl exists for the first secrets-push.
|
||
|
|
# Subsequent operations append.
|
||
|
|
if [[ ! -f "$VAULT_DIR/logs/access.jsonl" ]]; then
|
||
|
|
printf '{"ts":"%s","actor":"%s","op":"bootstrap","detail":"vault initialized"}\n' \
|
||
|
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}" \
|
||
|
|
> "$VAULT_DIR/logs/access.jsonl"
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ── Layer 2 (ADR-017) — scopes + registry credentials inside src-vault/ ──
|
||
|
|
# The src-vault/ subtree IS the OCI artifact contents. domain_client.nu reads
|
||
|
|
# registry/{ro,rw}.sops.yaml via credential_sops/_rw paths from the manifest.
|
||
|
|
SRC_VAULT_DIR="$VAULT_DIR/src-vault"
|
||
|
|
mkdir -p "$SRC_VAULT_DIR/scopes" "$SRC_VAULT_DIR/registry"
|
||
|
|
|
||
|
|
# Generate scope files for each unique role declared in actor_key_bindings.
|
||
|
|
# Default scopes by role-name convention; edit later via 'ore secrets open'.
|
||
|
|
ROLES=$(echo "$PROJECT_JSON" | jq -r '[.sops.actor_key_bindings | .[]] | unique | .[]')
|
||
|
|
for ROLE in $ROLES; do
|
||
|
|
SCOPE_FILE="$SRC_VAULT_DIR/scopes/${ROLE}.ncl"
|
||
|
|
if [[ -f "$SCOPE_FILE" ]]; then continue; fi
|
||
|
|
# RW for human-operator roles, RO for automation roles. Convention only.
|
||
|
|
case "$ROLE" in
|
||
|
|
admin|developer|jesus|supervisor)
|
||
|
|
ACCESS="'rw"
|
||
|
|
OPS="['pull, 'push, 'verify, 'list]"
|
||
|
|
;;
|
||
|
|
*)
|
||
|
|
ACCESS="'ro"
|
||
|
|
OPS="['pull, 'verify, 'list]"
|
||
|
|
;;
|
||
|
|
esac
|
||
|
|
printf '{\n role = "%s",\n access = %s,\n bound_actor = [],\n namespaces = ["domains/*/", "modes/*/"],\n ops = %s,\n}\n' \
|
||
|
|
"$ROLE" "$ACCESS" "$OPS" > "$SCOPE_FILE"
|
||
|
|
echo "Created scope: scopes/${ROLE}.ncl"
|
||
|
|
done
|
||
|
|
|
||
|
|
# Default Layer 2 credentials: registry/ro.sops.yaml + registry/rw.sops.yaml.
|
||
|
|
# Skipped in declarative mode (recipient_rules declared) — the operator names
|
||
|
|
# files per their tenancy scheme (e.g. clientA-ro, agent-readonly) and
|
||
|
|
# populates them via 'ore secrets open' after bootstrap. Legacy mode keeps
|
||
|
|
# the default ro/rw pair seeded with the same zot credentials for both.
|
||
|
|
if [[ "$HAS_RULES" == "true" ]]; then
|
||
|
|
echo "Skipping default registry/ro|rw.sops.yaml — declarative recipient_rules in effect."
|
||
|
|
echo "Populate registry/<file>.sops.yaml entries that match your declared rules."
|
||
|
|
else
|
||
|
|
L2_CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
|
||
|
|
L2_USER=$(echo "$L2_CREDS" | grep '^zot_username:' | awk '{print $2}')
|
||
|
|
L2_PASS=$(echo "$L2_CREDS" | grep '^zot_password:' | awk '{print $2}')
|
||
|
|
for KIND in ro rw; do
|
||
|
|
SOPS_FILE="$SRC_VAULT_DIR/registry/${KIND}.sops.yaml"
|
||
|
|
if [[ -f "$SOPS_FILE" ]]; then continue; fi
|
||
|
|
printf 'username: %s\npassword: %s\n' "$L2_USER" "$L2_PASS" \
|
||
|
|
| SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "${SOPS_ENC_FLAGS[@]}" \
|
||
|
|
--input-type yaml --encrypt /dev/stdin > "$SOPS_FILE"
|
||
|
|
echo "Created registry/${KIND}.sops.yaml"
|
||
|
|
done
|
||
|
|
unset L2_CREDS L2_USER L2_PASS
|
||
|
|
fi
|
||
|
|
|
||
|
|
echo "Bootstrap complete. Run 'ore secrets push' to upload the vault to ZOT."
|
||
|
|
|
||
|
|
# Pull the latest src-vault OCI artifact from ZOT and restore to local vault dir.
|
||
|
|
secrets-sync:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }
|
||
|
|
mkdir -p "$VAULT_DIR/logs"
|
||
|
|
|
||
|
|
[[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — first sync needs admin to push the vault"; exit 1; }
|
||
|
|
CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
|
||
|
|
ZOT_USER=$(echo "$CREDS" | grep zot_username | awk '{print $2}')
|
||
|
|
ZOT_PASS=$(echo "$CREDS" | grep zot_password | awk '{print $2}')
|
||
|
|
|
||
|
|
TMPDIR_CFG=$(mktemp -d)
|
||
|
|
AUTH_B64=$(printf '%s:%s' "$ZOT_USER" "$ZOT_PASS" | base64)
|
||
|
|
printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY}" "$AUTH_B64" > "$TMPDIR_CFG/config.json"
|
||
|
|
|
||
|
|
# Hard constraint (ADR-017 src-vault-cosign-signed): verify before trusting artifact.
|
||
|
|
[[ -n "$COSIGN_PUB_PATH" ]] || { rm -rf "$TMPDIR_CFG"; echo "vault.cosign.pub_path not set in ~/.config/ontoref/config.ncl"; exit 1; }
|
||
|
|
[[ -f "$COSIGN_PUB_PATH" ]] || { rm -rf "$TMPDIR_CFG"; echo "Cosign pubkey not found at $COSIGN_PUB_PATH"; exit 1; }
|
||
|
|
echo "Verifying src-vault/$VAULT_ID:latest signature (tlog=$COSIGN_TLOG)..."
|
||
|
|
cosign verify $COSIGN_VERIFY_FLAGS --key "$COSIGN_PUB_PATH" "${REGISTRY}/src-vault/${VAULT_ID}:latest" \
|
||
|
|
|| { rm -rf "$TMPDIR_CFG"; echo "Vault artifact signature FAILED — rejecting pull"; exit 1; }
|
||
|
|
|
||
|
|
DOCKER_CONFIG="$TMPDIR_CFG" oras pull "${REGISTRY}/src-vault/${VAULT_ID}:latest" --output "$VAULT_DIR"
|
||
|
|
|
||
|
|
rm -rf "$TMPDIR_CFG"
|
||
|
|
unset ZOT_USER ZOT_PASS CREDS AUTH_B64
|
||
|
|
echo "Vault synced to $VAULT_DIR"
|
||
|
|
|
||
|
|
LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"pull","detail":"secrets-sync"}' \
|
||
|
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}")
|
||
|
|
echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"
|
||
|
|
|
||
|
|
# Push the local vault snapshot to ZOT as an OCI artifact (cosign-signed).
|
||
|
|
secrets-push:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }
|
||
|
|
[[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml — run secrets-bootstrap first"; exit 1; }
|
||
|
|
|
||
|
|
# Hard constraint (ADR-017 src-vault-cosign-signed): sign required.
|
||
|
|
[[ -n "$COSIGN_KEY_PATH" ]] || { echo "vault.cosign.key_path not set in ~/.config/ontoref/config.ncl"; exit 1; }
|
||
|
|
[[ -f "$COSIGN_KEY_PATH" ]] || { echo "Cosign key not found at $COSIGN_KEY_PATH"; exit 1; }
|
||
|
|
|
||
|
|
CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
|
||
|
|
ZOT_USER=$(echo "$CREDS" | grep '^zot_username:' | awk '{print $2}')
|
||
|
|
ZOT_PASS=$(echo "$CREDS" | grep '^zot_password:' | awk '{print $2}')
|
||
|
|
VAULT_KEY_VAL=$(echo "$CREDS" | grep '^vault_key:' | awk '{print $2}')
|
||
|
|
# cosign_password optional for back-compat with vaults bootstrapped before
|
||
|
|
# the field existed; fallback to existing $COSIGN_PASSWORD env if unset.
|
||
|
|
COSIGN_PWD_VAL=$(echo "$CREDS" | grep '^cosign_password:' | awk '{print $2}')
|
||
|
|
|
||
|
|
echo "Creating vault snapshot..."
|
||
|
|
case "$VAULT_TOOL" in
|
||
|
|
restic) RESTIC_PASSWORD="$VAULT_KEY_VAL" restic -r "$VAULT_DIR/repo" backup "$VAULT_DIR" --tag "secrets-push" ;;
|
||
|
|
kopia) KOPIA_PASSWORD="$VAULT_KEY_VAL" kopia --config-file "$VAULT_DIR/repo/kopia.config" snapshot create "$VAULT_DIR" ;;
|
||
|
|
esac
|
||
|
|
unset VAULT_KEY_VAL
|
||
|
|
|
||
|
|
TMPDIR_CFG=$(mktemp -d)
|
||
|
|
AUTH_B64=$(printf '%s:%s' "$ZOT_USER" "$ZOT_PASS" | base64)
|
||
|
|
printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY}" "$AUTH_B64" > "$TMPDIR_CFG/config.json"
|
||
|
|
unset ZOT_USER ZOT_PASS CREDS AUTH_B64
|
||
|
|
|
||
|
|
[[ -f "$VAULT_DIR/logs/access.jsonl" ]] || {
|
||
|
|
echo "access.jsonl missing — re-run 'ore secrets bootstrap' to initialize"
|
||
|
|
rm -rf "$TMPDIR_CFG"
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
echo "Pushing src-vault/${VAULT_ID}:latest to $REGISTRY..."
|
||
|
|
# oras requires relative paths for layer files — cd into the vault dir.
|
||
|
|
# Walk src-vault/ to add scope files (.ncl), encrypted credentials (.sops.yaml),
|
||
|
|
# and recipient public keys (.age.pub) as typed layers.
|
||
|
|
LAYERS=("access.sops.yaml:application/vnd.ontoref.vault.access.v1"
|
||
|
|
"logs/access.jsonl:application/vnd.ontoref.vault.audit.v1")
|
||
|
|
if [[ -d "$VAULT_DIR/src-vault" ]]; then
|
||
|
|
while IFS= read -r f; do
|
||
|
|
REL="${f#${VAULT_DIR}/}"
|
||
|
|
case "$REL" in
|
||
|
|
src-vault/scopes/*.ncl) LAYERS+=("$REL:application/vnd.ontoref.vault.scope.v1") ;;
|
||
|
|
src-vault/registry/*.sops.yaml) LAYERS+=("$REL:application/vnd.ontoref.vault.credential.v1") ;;
|
||
|
|
esac
|
||
|
|
done < <(find "$VAULT_DIR/src-vault" -type f \( -name "*.ncl" -o -name "*.sops.yaml" \))
|
||
|
|
fi
|
||
|
|
|
||
|
|
PUSH_LOG=$(mktemp)
|
||
|
|
set +e
|
||
|
|
( cd "$VAULT_DIR" && DOCKER_CONFIG="$TMPDIR_CFG" oras push "${REGISTRY}/src-vault/${VAULT_ID}:latest" \
|
||
|
|
--artifact-type "application/vnd.ontoref.vault.v1" \
|
||
|
|
"${LAYERS[@]}" ) > "$PUSH_LOG" 2>&1
|
||
|
|
PUSH_EXIT=$?
|
||
|
|
set -e
|
||
|
|
cat "$PUSH_LOG"
|
||
|
|
if [[ $PUSH_EXIT -ne 0 ]]; then
|
||
|
|
rm -rf "$TMPDIR_CFG" "$PUSH_LOG"
|
||
|
|
echo "oras push failed (exit $PUSH_EXIT)"
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
VAULT_DIGEST=$(grep -oE 'sha256:[a-f0-9]{64}' "$PUSH_LOG" | head -1)
|
||
|
|
rm -f "$PUSH_LOG"
|
||
|
|
if [[ -z "$VAULT_DIGEST" ]]; then
|
||
|
|
rm -rf "$TMPDIR_CFG"
|
||
|
|
echo "Could not extract digest from oras push output — cannot sign"
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
IMAGE_REF="${REGISTRY}/src-vault/${VAULT_ID}@${VAULT_DIGEST}"
|
||
|
|
echo "Signing $IMAGE_REF (tlog=$COSIGN_TLOG)..."
|
||
|
|
# cosign needs DOCKER_CONFIG too — it HEADs the manifest before signing.
|
||
|
|
# COSIGN_PASSWORD: from access.sops.yaml when present, else fall back to env
|
||
|
|
# (back-compat with vaults bootstrapped before the field existed).
|
||
|
|
if [[ -n "$COSIGN_PWD_VAL" ]]; then
|
||
|
|
DOCKER_CONFIG="$TMPDIR_CFG" COSIGN_PASSWORD="$COSIGN_PWD_VAL" \
|
||
|
|
cosign sign $COSIGN_SIGN_FLAGS --key "$COSIGN_KEY_PATH" --yes "$IMAGE_REF"
|
||
|
|
else
|
||
|
|
DOCKER_CONFIG="$TMPDIR_CFG" \
|
||
|
|
cosign sign $COSIGN_SIGN_FLAGS --key "$COSIGN_KEY_PATH" --yes "$IMAGE_REF"
|
||
|
|
fi
|
||
|
|
unset COSIGN_PWD_VAL
|
||
|
|
echo "Vault artifact signed"
|
||
|
|
|
||
|
|
rm -rf "$TMPDIR_CFG"
|
||
|
|
|
||
|
|
LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"push","detail":"secrets-push digest=%s"}' \
|
||
|
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}" "$VAULT_DIGEST")
|
||
|
|
echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"
|
||
|
|
echo "Vault pushed and signed: $REGISTRY/src-vault/$VAULT_ID:latest"
|
||
|
|
|
||
|
|
# Open the vault for editing. Acquires an OCI lock at src-vault/<id>:lock with
|
||
|
|
# TTL (default 60 min). Conflicts: if another lock is active and not expired,
|
||
|
|
# error with the holder's identity. After editing, run 'ore secrets close'.
|
||
|
|
secrets-open:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
[[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — run secrets-sync first"; exit 1; }
|
||
|
|
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }
|
||
|
|
|
||
|
|
vault_zot_config_open || exit 1
|
||
|
|
trap 'rm -rf "$TMPDIR_CFG"' EXIT
|
||
|
|
|
||
|
|
NOW_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||
|
|
NOW_EPOCH=$(date -u +%s)
|
||
|
|
EXPIRES_EPOCH=$(( NOW_EPOCH + VAULT_LOCK_TTL_MIN * 60 ))
|
||
|
|
EXPIRES_TS=$(date -u -r "$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|
||
|
|
|| date -u -d "@$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|
||
|
|
|| echo "")
|
||
|
|
HOLDER="${USER:-$(whoami)}"
|
||
|
|
ACTOR="${ONTOREF_ACTOR:-developer}"
|
||
|
|
|
||
|
|
EXISTING=$(vault_lock_fetch)
|
||
|
|
if [[ -n "$EXISTING" ]]; then
|
||
|
|
EXIST_BY=$(echo "$EXISTING" | jq -r .locked_by)
|
||
|
|
EXIST_ACTOR=$(echo "$EXISTING" | jq -r .actor)
|
||
|
|
EXIST_SINCE=$(echo "$EXISTING" | jq -r .since)
|
||
|
|
EXIST_EXP=$(echo "$EXISTING" | jq -r .expires)
|
||
|
|
# Compare expires_epoch with now to detect abandoned lock.
|
||
|
|
EXIST_EXP_EPOCH=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$EXIST_EXP" +%s 2>/dev/null \
|
||
|
|
|| date -u -d "$EXIST_EXP" +%s 2>/dev/null || echo 0)
|
||
|
|
if [[ "$EXIST_BY" == "$HOLDER" ]]; then
|
||
|
|
echo "Vault already locked by you ($HOLDER, since $EXIST_SINCE). Refreshing TTL."
|
||
|
|
elif [[ "$EXIST_EXP_EPOCH" -gt "$NOW_EPOCH" ]]; then
|
||
|
|
echo "Vault locked by $EXIST_BY (actor=$EXIST_ACTOR) since $EXIST_SINCE, expires $EXIST_EXP."
|
||
|
|
echo "If lock is abandoned and you have admin rights, run: ore secrets force-unlock"
|
||
|
|
exit 1
|
||
|
|
else
|
||
|
|
echo "Lock expired ($EXIST_EXP < $NOW_TS). Force-closing abandoned lock from $EXIST_BY."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
vault_lock_push "$HOLDER" "$ACTOR" "$NOW_TS" "$EXPIRES_TS" \
|
||
|
|
|| { echo "Failed to push lock artifact (registry ACL may not allow)"; exit 1; }
|
||
|
|
echo "Lock acquired: $HOLDER until $EXPIRES_TS"
|
||
|
|
|
||
|
|
echo "Opening vault $VAULT_ID for editing..."
|
||
|
|
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "$ACCESS_SOPS"
|
||
|
|
echo "Edit complete. Run 'ore secrets close' to push and release the lock."
|
||
|
|
|
||
|
|
# Close the vault: impact report → push updated state → release lock.
|
||
|
|
# ADR-017 G2: list which credential files changed and which RegistryEntries
|
||
|
|
# they affect. Operator confirms before the push proceeds.
|
||
|
|
secrets-close:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
|
||
|
|
# Pre-flight impact analysis.
|
||
|
|
CHANGED=$(vault_impact_changed_creds)
|
||
|
|
if [[ -n "$CHANGED" ]]; then
|
||
|
|
echo "──────────────────────────────────────────────────────────────────"
|
||
|
|
echo " Credential changes detected since last snapshot:"
|
||
|
|
echo "$CHANGED" | sed 's/^/ /'
|
||
|
|
echo
|
||
|
|
IMPACT=$(echo "$CHANGED" | vault_impact_registries)
|
||
|
|
if [[ -n "$IMPACT" ]]; then
|
||
|
|
echo " Affected RegistryEntry references:"
|
||
|
|
echo "$IMPACT" | awk -F'\t' '{ printf " %s via %s (%s)\n", $1, $2, $3 }'
|
||
|
|
echo
|
||
|
|
echo " Domains/modes declaring uses_registry to any of the entries above"
|
||
|
|
echo " will resolve fresh credentials on next pull. Audit consumers if any"
|
||
|
|
echo " service uses cached credentials in ~/.cache/ontoref/."
|
||
|
|
else
|
||
|
|
echo " No RegistryEntry in this project's manifest references the changed file(s)."
|
||
|
|
echo " Consumers in OTHER projects may still depend on these credentials."
|
||
|
|
fi
|
||
|
|
echo "──────────────────────────────────────────────────────────────────"
|
||
|
|
if [[ -z "${ONTOREF_SECRETS_YES:-}" ]]; then
|
||
|
|
read -r -p " Proceed with push? [y/N] " ans
|
||
|
|
[[ "$ans" =~ ^[Yy] ]] || { echo "Aborted. Lock remains held."; exit 1; }
|
||
|
|
fi
|
||
|
|
else
|
||
|
|
echo "No credential changes since last snapshot."
|
||
|
|
fi
|
||
|
|
|
||
|
|
just secrets-push
|
||
|
|
vault_zot_config_open || { echo "[close] cannot resolve ZOT config — lock NOT released"; exit 1; }
|
||
|
|
trap 'rm -rf "$TMPDIR_CFG"' EXIT
|
||
|
|
EXISTING=$(vault_lock_fetch)
|
||
|
|
if [[ -z "$EXISTING" ]]; then
|
||
|
|
echo "No lock to release."
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
EXIST_BY=$(echo "$EXISTING" | jq -r .locked_by)
|
||
|
|
HOLDER="${USER:-$(whoami)}"
|
||
|
|
if [[ "$EXIST_BY" != "$HOLDER" ]]; then
|
||
|
|
echo "Lock held by $EXIST_BY, not $HOLDER. Refusing to release. Use force-unlock if needed."
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
vault_lock_release
|
||
|
|
echo "Lock released."
|
||
|
|
|
||
|
|
# Force-release an abandoned or stuck lock (admin only).
|
||
|
|
# Lock metadata records who force-closed for auditability.
|
||
|
|
secrets-force-unlock:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared"; exit 1; }
|
||
|
|
vault_zot_config_open || exit 1
|
||
|
|
trap 'rm -rf "$TMPDIR_CFG"' EXIT
|
||
|
|
EXISTING=$(vault_lock_fetch)
|
||
|
|
if [[ -z "$EXISTING" ]]; then
|
||
|
|
echo "No lock to release."
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
EXIST_BY=$(echo "$EXISTING" | jq -r .locked_by)
|
||
|
|
EXIST_SINCE=$(echo "$EXISTING" | jq -r .since)
|
||
|
|
HOLDER="${USER:-$(whoami)}"
|
||
|
|
NOW_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||
|
|
echo "Force-unlocking vault $VAULT_ID."
|
||
|
|
echo " was held by: $EXIST_BY since $EXIST_SINCE"
|
||
|
|
echo " force-closed by: $HOLDER at $NOW_TS"
|
||
|
|
# Emit a tombstone-tagged audit before delete so the action is traceable.
|
||
|
|
LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"force-unlock","detail":"was-held-by=%s since=%s"}' \
|
||
|
|
"$NOW_TS" "${ONTOREF_ACTOR:-developer}" "$EXIST_BY" "$EXIST_SINCE")
|
||
|
|
echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"
|
||
|
|
vault_lock_release
|
||
|
|
echo "Lock released."
|
||
|
|
|
||
|
|
# Add a recipient. Two modes:
|
||
|
|
# - DECLARATIVE (project declares recipient_rules): error with guidance to edit
|
||
|
|
# project.ncl::sops.recipient_groups and run 'secrets rekey' — that is the
|
||
|
|
# canonical source of truth. Mutating sops files directly would diverge from
|
||
|
|
# the declared state and the next rekey would re-clobber the change.
|
||
|
|
# - LEGACY (no rules declared): apply --add-age across every *.sops.yaml in the vault.
|
||
|
|
secrets-add-key AGE_PUBKEY ROLE:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '(.sops.recipient_rules // []) | length > 0')
|
||
|
|
if [[ "$HAS_RULES" == "true" ]]; then
|
||
|
|
echo "This vault uses declarative recipient_rules — direct add-key would drift."
|
||
|
|
echo "Edit project.ncl: add the pubkey to sops.recipient_groups[\"{{ROLE}}\"]"
|
||
|
|
echo "Then: ore secrets rekey"
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
echo "Adding recipient (role: {{ROLE}}) to vault $VAULT_ID..."
|
||
|
|
while IFS= read -r f; do
|
||
|
|
echo " + ${f#${VAULT_DIR}/}"
|
||
|
|
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys --add-age "{{AGE_PUBKEY}}" "$f" >/dev/null
|
||
|
|
done < <(find "$VAULT_DIR" -name "*.sops.yaml")
|
||
|
|
echo "Recipient added to $(find "$VAULT_DIR" -name "*.sops.yaml" | wc -l | tr -d ' ') file(s)."
|
||
|
|
echo "Run 'ore secrets push' to distribute the updated vault."
|
||
|
|
|
||
|
|
# Remove a recipient. Same DECLARATIVE/LEGACY split as add-key.
|
||
|
|
secrets-remove-key AGE_PUBKEY:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '(.sops.recipient_rules // []) | length > 0')
|
||
|
|
if [[ "$HAS_RULES" == "true" ]]; then
|
||
|
|
echo "This vault uses declarative recipient_rules — direct remove-key would drift."
|
||
|
|
echo "Edit project.ncl: remove the pubkey from sops.recipient_groups[*]"
|
||
|
|
echo "Then: ore secrets rekey"
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
echo "Removing recipient from vault $VAULT_ID..."
|
||
|
|
while IFS= read -r f; do
|
||
|
|
echo " - ${f#${VAULT_DIR}/}"
|
||
|
|
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys --remove-age "{{AGE_PUBKEY}}" "$f" >/dev/null
|
||
|
|
done < <(find "$VAULT_DIR" -name "*.sops.yaml")
|
||
|
|
echo "Recipient removed from $(find "$VAULT_DIR" -name "*.sops.yaml" | wc -l | tr -d ' ') file(s)."
|
||
|
|
echo "Rotate registry tokens if the removed key was suspected of compromise."
|
||
|
|
|
||
|
|
# Re-encrypt all sops files. When project declares recipient_rules, regenerate
|
||
|
|
# <vault_dir>/.sops.yaml from project.ncl FIRST so updatekeys reads the fresh
|
||
|
|
# recipient unions per path. Otherwise, updatekeys uses whatever .sops.yaml
|
||
|
|
# already exists (legacy mode).
|
||
|
|
secrets-rekey:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
RULES_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
|
||
|
|
GROUPS_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
|
||
|
|
HAS_RULES=$(echo "$RULES_JSON" | jq 'length > 0')
|
||
|
|
|
||
|
|
if [[ "$HAS_RULES" == "true" ]]; then
|
||
|
|
SOPS_CFG_PATH="$VAULT_DIR/.sops.yaml"
|
||
|
|
echo "creation_rules:" > "$SOPS_CFG_PATH"
|
||
|
|
# Use YAML single-quoted strings for path_regex so backslash escapes
|
||
|
|
# like \\. pass through to sops without YAML interpreting them.
|
||
|
|
echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
|
||
|
|
.[] | " - path_regex: '"'"'\(.path)'"'"'\n age: " +
|
||
|
|
([.groups[] | $groups[.] // []] | flatten | unique | join(","))
|
||
|
|
' >> "$SOPS_CFG_PATH"
|
||
|
|
echo "Regenerated $SOPS_CFG_PATH from project.ncl ($(echo "$RULES_JSON" | jq length) rule(s))."
|
||
|
|
fi
|
||
|
|
|
||
|
|
echo "Re-keying all sops files in $VAULT_DIR..."
|
||
|
|
find "$VAULT_DIR" -name "*.sops.yaml" | while read -r f; do
|
||
|
|
echo " updatekeys: $f"
|
||
|
|
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys "$f"
|
||
|
|
done
|
||
|
|
echo "Re-key complete. Run 'ore secrets push' to distribute."
|
||
|
|
|
||
|
|
# Add or update the cosign_password field inside access.sops.yaml without
|
||
|
|
# opening the editor. Targets vaults bootstrapped before the field existed.
|
||
|
|
# Idempotent: if the field is already present, value is replaced.
|
||
|
|
# Recover a corrupted/missing access.sops.yaml by pulling the src-vault OCI
|
||
|
|
# artifact from ZOT. Verified disaster-recovery flow (qa.ncl::credential-vault-
|
||
|
|
# disaster-recovery). Requires the project.ncl::sops.registry_endpoint and
|
||
|
|
# valid ZOT credentials in your environment via DOCKER_CONFIG (or admin
|
||
|
|
# credentials passed inline).
|
||
|
|
secrets-recover:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
[[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared"; exit 1; }
|
||
|
|
|
||
|
|
# When access.sops.yaml is corrupt we cannot decrypt it to obtain credentials.
|
||
|
|
# Caller must provide ZOT credentials via env (RECOVERY_ZOT_USER + _PASS) OR
|
||
|
|
# have a pre-existing valid DOCKER_CONFIG pointing at this registry.
|
||
|
|
if [[ -n "${RECOVERY_ZOT_USER:-}" && -n "${RECOVERY_ZOT_PASS:-}" ]]; then
|
||
|
|
TMPCFG=$(mktemp -d)
|
||
|
|
AUTH=$(printf '%s:%s' "$RECOVERY_ZOT_USER" "$RECOVERY_ZOT_PASS" | base64)
|
||
|
|
printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY" "$AUTH" > "$TMPCFG/config.json"
|
||
|
|
export DOCKER_CONFIG="$TMPCFG"
|
||
|
|
trap 'rm -rf "$TMPCFG"' EXIT
|
||
|
|
echo "Using RECOVERY_ZOT_USER credentials."
|
||
|
|
elif [[ -n "${DOCKER_CONFIG:-}" ]]; then
|
||
|
|
echo "Using existing DOCKER_CONFIG=$DOCKER_CONFIG"
|
||
|
|
else
|
||
|
|
echo "No credentials available. Set RECOVERY_ZOT_USER + RECOVERY_ZOT_PASS"
|
||
|
|
echo "or DOCKER_CONFIG before invoking."
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
RECOVERY=$(mktemp -d)
|
||
|
|
echo "Pulling src-vault/${VAULT_ID}:latest from $REGISTRY..."
|
||
|
|
if ! oras pull "${REGISTRY}/src-vault/${VAULT_ID}:latest" --output "$RECOVERY"; then
|
||
|
|
rm -rf "$RECOVERY"
|
||
|
|
echo "oras pull failed — verify registry and credentials"
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
[[ -f "$RECOVERY/access.sops.yaml" ]] || {
|
||
|
|
rm -rf "$RECOVERY"
|
||
|
|
echo "Recovered artifact does not contain access.sops.yaml"
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
mkdir -p "$VAULT_DIR" "$VAULT_DIR/logs" "$VAULT_DIR/src-vault"
|
||
|
|
cp "$RECOVERY/access.sops.yaml" "$ACCESS_SOPS"
|
||
|
|
[[ -d "$RECOVERY/logs" ]] && cp -R "$RECOVERY/logs/." "$VAULT_DIR/logs/" 2>/dev/null || true
|
||
|
|
[[ -d "$RECOVERY/src-vault" ]] && cp -R "$RECOVERY/src-vault/." "$VAULT_DIR/src-vault/" 2>/dev/null || true
|
||
|
|
rm -rf "$RECOVERY"
|
||
|
|
|
||
|
|
# Verify decrypts cleanly with the configured master key.
|
||
|
|
if SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS" >/dev/null 2>&1; then
|
||
|
|
echo "Recovered. access.sops.yaml decrypts with $MASTER_KEY"
|
||
|
|
printf '{"ts":"%s","actor":"%s","op":"recover","detail":"access.sops.yaml restored from %s"}\n' \
|
||
|
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-developer}" \
|
||
|
|
"${REGISTRY}/src-vault/${VAULT_ID}:latest" >> "$VAULT_DIR/logs/access.jsonl"
|
||
|
|
else
|
||
|
|
echo "Restored but cannot decrypt — your .kage may not be a recipient"
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
secrets-set-cosign-password:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
[[ -f "$ACCESS_SOPS" ]] || { echo "access.sops.yaml absent — bootstrap first"; exit 1; }
|
||
|
|
|
||
|
|
read -r -s -p "cosign_password (for cosign.key): " COSIGN_PWD; echo
|
||
|
|
[[ -n "$COSIGN_PWD" ]] || { echo "empty password rejected"; exit 1; }
|
||
|
|
|
||
|
|
# `sops set` updates a single field in-place, preserving the original
|
||
|
|
# recipient set (which lives in the file's sops metadata block). No
|
||
|
|
# decrypt + re-encrypt cycle, no leak of plaintext to disk.
|
||
|
|
SOPS_AGE_KEY_FILE="$MASTER_KEY" sops set "$ACCESS_SOPS" '["cosign_password"]' "\"$COSIGN_PWD\""
|
||
|
|
unset COSIGN_PWD
|
||
|
|
|
||
|
|
# Verify the field is present after the update.
|
||
|
|
if SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt --extract '["cosign_password"]' "$ACCESS_SOPS" >/dev/null 2>&1; then
|
||
|
|
echo " cosign_password set in $ACCESS_SOPS"
|
||
|
|
echo " Run 'ore secrets push' to upload the updated vault."
|
||
|
|
else
|
||
|
|
echo " set succeeded but field still not extractable — investigate"
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Audit vault constraints from ADR-017:
|
||
|
|
# --check bootstrap-credentials access.sops.yaml can be decrypted by master_key
|
||
|
|
# --check no-credential-env manifest does not declare credential_env
|
||
|
|
# --check recipients every .sops.yaml has ≥ 2 recipients (multi-recipient mandatory)
|
||
|
|
secrets-audit check="":
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
|
||
|
|
CHECK="${check:-all}"
|
||
|
|
PASS=0
|
||
|
|
|
||
|
|
if [[ "$CHECK" == "bootstrap-credentials" ]] || [[ "$CHECK" == "all" ]]; then
|
||
|
|
echo -n " bootstrap-credentials: "
|
||
|
|
if [[ ! -f "$ACCESS_SOPS" ]]; then
|
||
|
|
echo "FAIL (access.sops.yaml not found at $ACCESS_SOPS)"
|
||
|
|
PASS=1
|
||
|
|
else
|
||
|
|
DECRYPT_CHECK=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS" 2>&1)
|
||
|
|
if [[ $? -eq 0 ]]; then echo "OK"; else echo "FAIL (decrypt error)"; PASS=1; fi
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [[ "$CHECK" == "no-credential-env" ]] || [[ "$CHECK" == "all" ]]; then
|
||
|
|
echo -n " no-credential-env: "
|
||
|
|
if grep -r "credential_env" "${PROJECT_ROOT}/.ontology/manifest.ncl" 2>/dev/null | grep -q .; then
|
||
|
|
echo "FAIL (credential_env found in manifest.ncl — excluded by schema)"
|
||
|
|
PASS=1
|
||
|
|
else
|
||
|
|
echo "OK"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [[ "$CHECK" == "recipients" ]] || [[ "$CHECK" == "all" ]]; then
|
||
|
|
echo -n " multi-recipient-mandatory: "
|
||
|
|
if [[ -f "$ACCESS_SOPS" ]]; then
|
||
|
|
RECIPIENT_COUNT=$(grep -c "age1" "$ACCESS_SOPS" 2>/dev/null || echo 0)
|
||
|
|
if [[ "$RECIPIENT_COUNT" -ge 2 ]]; then echo "OK ($RECIPIENT_COUNT recipients)";
|
||
|
|
else echo "FAIL (only $RECIPIENT_COUNT recipient — multi-recipient required)"; PASS=1; fi
|
||
|
|
else
|
||
|
|
echo "SKIP (no access.sops.yaml)"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ADR-019 — recipient routing constraints (skipped in legacy mode).
|
||
|
|
RULES=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
|
||
|
|
GROUPS=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
|
||
|
|
HAS_RULES=$(echo "$RULES" | jq 'length > 0')
|
||
|
|
|
||
|
|
if [[ "$CHECK" == "recipient-routing-coherent" ]] || [[ "$CHECK" == "all" ]]; then
|
||
|
|
echo -n " recipient-routing-coherent: "
|
||
|
|
if [[ "$HAS_RULES" != "true" ]]; then
|
||
|
|
echo "SKIP (legacy single-set mode)"
|
||
|
|
else
|
||
|
|
# Constraint 1: every group referenced by a rule must be declared in groups
|
||
|
|
UNDECLARED=$(echo "$RULES" | jq -r --argjson g "$GROUPS" '
|
||
|
|
[.[] | .groups[] | select(($g | has(.)) | not)] | unique | join(",")
|
||
|
|
')
|
||
|
|
# Constraint 2: every rule must resolve to ≥ 1 recipient
|
||
|
|
EMPTY_RULES=$(echo "$RULES" | jq -r --argjson g "$GROUPS" '
|
||
|
|
[.[] | select((.groups | map($g[.] // []) | flatten | unique | length) == 0) | .path] | join(",")
|
||
|
|
')
|
||
|
|
if [[ -n "$UNDECLARED" ]]; then
|
||
|
|
echo "FAIL (undeclared groups referenced: $UNDECLARED)"
|
||
|
|
PASS=1
|
||
|
|
elif [[ -n "$EMPTY_RULES" ]]; then
|
||
|
|
echo "FAIL (rules resolve to zero recipients: $EMPTY_RULES)"
|
||
|
|
PASS=1
|
||
|
|
else
|
||
|
|
echo "OK ($(echo "$RULES" | jq length) rule(s), $(echo "$GROUPS" | jq 'keys | length') group(s))"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [[ "$CHECK" == "recipient-routing-coverage" ]] || [[ "$CHECK" == "all" ]]; then
|
||
|
|
echo -n " recipient-routing-coverage: "
|
||
|
|
if [[ "$HAS_RULES" != "true" ]]; then
|
||
|
|
echo "SKIP (legacy single-set mode)"
|
||
|
|
elif [[ ! -d "$VAULT_DIR" ]]; then
|
||
|
|
echo "SKIP (vault not bootstrapped)"
|
||
|
|
else
|
||
|
|
# For each *.sops.yaml in the vault, verify at least one rule.path matches.
|
||
|
|
UNCOVERED=""
|
||
|
|
while IFS= read -r f; do
|
||
|
|
rel="${f#${VAULT_DIR}/}"
|
||
|
|
# Try each rule's regex; rule order doesn't matter for coverage.
|
||
|
|
MATCHED=$(echo "$RULES" | jq -r --arg path "$rel" '
|
||
|
|
[.[] | select($path | test(.path))] | length
|
||
|
|
')
|
||
|
|
[[ "$MATCHED" == "0" ]] && UNCOVERED="$UNCOVERED $rel"
|
||
|
|
done < <(find "$VAULT_DIR" -name "*.sops.yaml" 2>/dev/null)
|
||
|
|
if [[ -n "$UNCOVERED" ]]; then
|
||
|
|
echo "FAIL (paths without matching rule:$UNCOVERED)"
|
||
|
|
PASS=1
|
||
|
|
else
|
||
|
|
echo "OK"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [[ "$CHECK" == "no-multi-vault" ]] || [[ "$CHECK" == "all" ]]; then
|
||
|
|
echo -n " no-multi-vault: "
|
||
|
|
if grep -q "sops\.vaults *=" "$PROJECT_NCL" 2>/dev/null; then
|
||
|
|
echo "FAIL (sops.vaults declaration found — multi-vault is unimplemented per ADR-019)"
|
||
|
|
PASS=1
|
||
|
|
else
|
||
|
|
echo "OK"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
exit $PASS
|
||
|
|
|
||
|
|
# Read existing access.sops.yaml recipients and emit a recipient_groups +
|
||
|
|
# recipient_rules stanza ready to paste into project.ncl::sops. Read-only —
|
||
|
|
# does not mutate project.ncl. The default policy maps every existing recipient
|
||
|
|
# to BOTH `admin` and `developer` groups (preserves current behaviour exactly)
|
||
|
|
# and emits two rules: one for access.sops.yaml (admin+developer), one for
|
||
|
|
# everything else (developer-only). Operator edits the stanza before pasting
|
||
|
|
# to refine which keys belong to which group.
|
||
|
|
secrets-migrate-to-declarative:
|
||
|
|
#!/usr/bin/env bash
|
||
|
|
source "{{source_directory()}}/_secrets_lib.sh"
|
||
|
|
[[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — bootstrap or sync first"; exit 1; }
|
||
|
|
|
||
|
|
# Already declarative? Refuse to overwrite — operator must rekey from project.ncl.
|
||
|
|
HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '.sops.recipient_rules // [] | length > 0')
|
||
|
|
if [[ "$HAS_RULES" == "true" ]]; then
|
||
|
|
echo "[migrate] project.ncl already declares recipient_rules — nothing to migrate."
|
||
|
|
echo " Run 'just secrets-rekey' if you changed recipient_groups and need to re-encrypt."
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Extract recipients from the existing access.sops.yaml metadata. sops
|
||
|
|
# records them under sops.age[].recipient as age1... entries — file format
|
||
|
|
# may be YAML or JSON depending on input-type, so use a generic regex.
|
||
|
|
RECIPIENTS=$(grep -oE 'age1[a-z0-9]+' "$ACCESS_SOPS" | sort -u)
|
||
|
|
[[ -n "$RECIPIENTS" ]] || { echo "[migrate] no age recipients found in $ACCESS_SOPS"; exit 1; }
|
||
|
|
COUNT=$(echo "$RECIPIENTS" | wc -l | tr -d ' ')
|
||
|
|
|
||
|
|
echo "# ── recipient routing migration helper (read-only) ────────────────────"
|
||
|
|
echo "# Detected $COUNT recipient(s) in $ACCESS_SOPS:"
|
||
|
|
echo "$RECIPIENTS" | sed 's/^/# - /'
|
||
|
|
echo "#"
|
||
|
|
echo "# Default policy: all recipients in both 'admin' and 'developer' groups."
|
||
|
|
echo "# This preserves current behaviour exactly (no key loses access). Edit"
|
||
|
|
echo "# the groups below to scope down before pasting into project.ncl::sops."
|
||
|
|
echo "#"
|
||
|
|
echo "# Paste THIS into your project.ncl::sops record (replacing any existing"
|
||
|
|
echo "# recipient_groups / recipient_rules / SOPS_AGE_RECIPIENTS env reliance):"
|
||
|
|
echo "# ──────────────────────────────────────────────────────────────────────"
|
||
|
|
echo
|
||
|
|
AGE_LIST=$(echo "$RECIPIENTS" | awk '{printf " \"%s\",\n", $0}')
|
||
|
|
printf ' recipient_groups = {\n'
|
||
|
|
printf ' admin = [\n%s\n ],\n' "$AGE_LIST"
|
||
|
|
printf ' developer = [\n%s\n ],\n' "$AGE_LIST"
|
||
|
|
printf ' },\n'
|
||
|
|
printf ' recipient_rules = [\n'
|
||
|
|
printf ' { path = "access\\\\.sops\\\\.yaml$", groups = ["admin", "developer"] },\n'
|
||
|
|
printf ' { path = "registry/.*\\\\.sops\\\\.yaml$", groups = ["developer"] },\n'
|
||
|
|
printf ' { path = "scopes/.*\\\\.ncl$", groups = ["admin"] },\n'
|
||
|
|
printf ' ],\n'
|
||
|
|
echo
|
||
|
|
echo "# ──────────────────────────────────────────────────────────────────────"
|
||
|
|
echo "# Next steps:"
|
||
|
|
echo "# 1. Edit project.ncl::sops to insert the stanza above."
|
||
|
|
echo "# 2. Refine groups (move keys between admin/developer to scope access)."
|
||
|
|
echo "# 3. Refine recipient_rules paths to match your repo layout."
|
||
|
|
echo "# 4. Run: just secrets-rekey (re-encrypts every *.sops.yaml using"
|
||
|
|
echo "# the new declared rules — same recipients = idempotent no-op)."
|
||
|
|
echo "# 5. Verify: just secrets-audit (runs ADR-019 coherency checks)."
|