Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions k8s/bases/apps/ascoachingogvaner/oci-repository.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ spec:
verify:
provider: cosign
matchOIDCIdentity:
# Matches the issuer and the shared publish-app.yaml workflow path at a 40-hex
# commit: the commit shape, not a reviewed revision. Package write access,
# namespace, RBAC, network and admission policy bound the release; the remaining
# risk is accepted in devantler-tech/platform#3746.
# The subject is written from scripts/publish-workflow-approved-revisions.tsv by
# scripts/write-publish-workflow-matchers.sh. It accepts only the approved
# publish-app.yaml revisions: the deployed artifact signer, this consumer's
# current pin, and the latest actions release candidate.
- issuer: '^https://token\.actions\.githubusercontent\.com$'
subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}$'
subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(a147ead8b8bc937fbfb1974e3698c7e32f95517d|3c3a6348b3e7aacc995ae49fc60032f95f70a779|6f47d8ca360fa3058f57175d54fa148deac08983)$'
10 changes: 5 additions & 5 deletions k8s/bases/apps/wedding-app/oci-repository.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ spec:
verify:
provider: cosign
matchOIDCIdentity:
# Matches the issuer and the shared publish-app.yaml workflow path at a 40-hex
# commit: the commit shape, not a reviewed revision. Package write access,
# namespace, RBAC, network and admission policy bound the release; the remaining
# risk is accepted in devantler-tech/platform#3746.
# The subject is written from scripts/publish-workflow-approved-revisions.tsv by
# scripts/write-publish-workflow-matchers.sh. It accepts only the approved
# publish-app.yaml revisions: the deployed artifact signer, this consumer's
# current pin, and the latest actions release candidate.
- issuer: '^https://token\.actions\.githubusercontent\.com$'
subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}$'
subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(759ff9b2526cd1b142b49c5dc1616118a1ef44d9|3c3a6348b3e7aacc995ae49fc60032f95f70a779|6f47d8ca360fa3058f57175d54fa148deac08983)$'
38 changes: 11 additions & 27 deletions scripts/guard-publish-workflow-approved-revisions.sh
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
#!/usr/bin/env bash
# guard-publish-workflow-approved-revisions.sh — assert that each per-consumer cosign matcher
# for the shared devantler-tech/actions publish workflows stays inside its declared trust
# boundary: trusted tenant release streams accept any immutable shared-workflow commit, while
# infrastructure/configuration consumers pin exactly the generated revision pair.
# for the shared devantler-tech/actions publish workflows names exactly its generated approved
# revision set.
#
# WHY A SECOND GUARD
# guard-shared-publish-workflow-pin.sh proves every matcher pins a FIXED revision — the pattern
# text `[0-9a-f]{40}`, one concrete commit, or an alternation of two. It judges ref SHAPE and
# says nothing about set MEMBERSHIP: a matcher narrowed to a revision nobody approved, or a
# regeneration that moved the set while a matcher stayed behind, passes it. This guard reads
# scripts/publish-workflow-approved-revisions.tsv (written by
# generate-publish-workflow-approved-revisions.sh, #3550). Bounded consumers must name their
# generated pair and nothing else. Trusted release streams must name the immutable commit
# pattern and nothing broader, which preserves the issuer/workflow boundary without a release
# approval gate.
# generate-publish-workflow-approved-revisions.sh, #3550). Every consumer, application tenants
# included, must name its approved set and nothing else: the applied signer, the default-branch
# pin and, for publish-app consumers, the latest released actions revision (#3917).
#
# ENFORCEMENT
# CI and scheduled regeneration set APPROVED_REVISIONS_ENFORCE=1, which refuses the pattern
# form for bounded infrastructure/configuration consumers. Trusted release streams require
# that form in every mode so tenant releases never create a platform-side repin.
# form for every consumer.
#
# SCOPE — GENERIC SUBJECTS ARE EXCLUDED BY NAME
# Three subjects name the shared workflows without belonging to one consumer: the Kyverno
Expand All @@ -36,7 +33,7 @@
# SEAMS (tests substitute these; production leaves them unset)
# APPROVED_REVISIONS_FILE the generated set (default scripts/publish-workflow-approved-revisions.tsv)
# PUBLISH_CONSUMER_ROOT scan root for consumer manifests (default: the repository root)
# APPROVED_REVISIONS_ENFORCE `1` refuses pattern form on bounded consumers (default: off)
# APPROVED_REVISIONS_ENFORCE `1` refuses the pattern form (default: off)
set -euo pipefail

GUARD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -48,34 +45,21 @@ for consumer in "${EXPECTED_CONSUMERS[@]}"; do
record="$(lookup "$observed" "$consumer")"
[ -n "$record" ] || refuse "no OCIRepository under $SCAN_ROOT is attributed to registered consumer $consumer; the scan, not the tree, is the likely cause"
IFS=$'\t' read -r file workflow ref <<<"$record"
IFS=$'\t' read -r set_workflow signer pin <<<"$(lookup "$approved" "$consumer")"
IFS=$'\t' read -r set_workflow signer pin candidate <<<"$(lookup "$approved" "$consumer")"
[ "$workflow" = "$set_workflow" ] || refuse "$consumer: $file names $workflow but the approved set names $set_workflow"

if is_trusted_release_stream_consumer "$consumer"; then
[ "$ref" = "$PATTERN_REF" ] ||
refuse "$consumer: trusted release stream $file pins '@$ref'; use the immutable shared-workflow pattern '@$PATTERN_REF' so tenant releases do not require platform approval"
printf 'ok %s %s %s form=trusted-release-stream\n' "$consumer" "$workflow" "$file"
continue
fi

if [ "$signer" = "$pin" ]; then
accepted="$signer or ($signer)"
else
accepted="($signer|$pin) or ($pin|$signer)"
fi
accepted="$(approved_ref "$signer" "$pin" "$candidate") (in any order)"

form=""
if [ "$ref" = "$PATTERN_REF" ]; then
if [ "$ENFORCE" = "1" ]; then
refuse "$consumer: $file still carries the pattern form '@$PATTERN_REF' while APPROVED_REVISIONS_ENFORCE=1; narrow it to @$accepted"
fi
form="pattern (accepted while the switch is off)"
elif [ "$signer" = "$pin" ] && { [ "$ref" = "$signer" ] || [ "$ref" = "($signer)" ]; }; then
form="set"
elif [ "$signer" != "$pin" ] && { [ "$ref" = "($signer|$pin)" ] || [ "$ref" = "($pin|$signer)" ]; }; then
elif ref_matches_approved "$ref" "$signer" "$pin" "$candidate"; then
form="set"
else
refuse "$consumer: $file pins '@$ref', which is not the generated pair — expected @$accepted (or the pattern form while the switch is off)"
refuse "$consumer: $file pins '@$ref', which is not the generated set — expected @$accepted (or the pattern form while the switch is off); run scripts/write-publish-workflow-matchers.sh"
fi
printf 'ok %s %s %s form=%s\n' "$consumer" "$workflow" "$file" "$form"
done
Expand Down
63 changes: 48 additions & 15 deletions scripts/publish-workflow-approved-revisions.lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,12 @@ readonly GENERIC_SUBJECT_FILES=(
'talos/cluster/verify-first-party-images.yaml'
)

# Application tenants are trusted to release independently inside the platform-owned
# boundary: exact GitHub OIDC issuer, exact shared workflow path, and an immutable 40-hex
# workflow commit. Pinning these streams to the currently observed workflow revisions would
# make every ordinary tenant release wait for a platform change. Infrastructure/configuration
# consumers remain tied to their generated revision sets below.
readonly TRUSTED_RELEASE_STREAM_CONSUMERS=(
'ascoachingogvaner'
'wedding-app'
)
# Every registered consumer, application tenants included, is narrowed to its approved revision
# set: the revision that signed the running artifact, the revision its default branch pins, and,
# for publish-app consumers, the latest released devantler-tech/actions revision (#3960). A tenant
# cannot move its pin ahead of that set: each app tenant runs a required check that refuses a pin
# the committed approved set does not contain (#3961), so a new shared-workflow revision holds the
# tenant's dependency PR rather than failing its release.

readonly SUBJECT_PREFIX='^https://github\.com/devantler-tech/actions/\.github/workflows/publish-'
# Used by both callers after the shared discovery completes.
Expand Down Expand Up @@ -63,12 +60,48 @@ lookup() {
printf '%s\n' "$1" | awk -F'\t' -v k="$2" '$1 "" == k "" { sub(/^[^\t]*\t/, ""); print; exit }'
}

is_trusted_release_stream_consumer() {
local candidate="$1" trusted
for trusted in "${TRUSTED_RELEASE_STREAM_CONSUMERS[@]}"; do
[ "$candidate" = "$trusted" ] && return 0
# approved_revisions <signer> <pin> <candidate> → each distinct approved revision on its own line,
# in signer, pin, candidate order. A `-` candidate (publish-manifests rows) names no revision.
approved_revisions() {
local seen=" " rev
for rev in "$@"; do
[ "$rev" != '-' ] || continue
case "$seen" in *" $rev "*) continue ;; esac
seen="$seen$rev "
printf '%s\n' "$rev"
done
return 1
}

# approved_ref <signer> <pin> <candidate> → the canonical matcher ref: the single revision, or a
# grouped alternation of the distinct revisions in approved_revisions order.
approved_ref() {
local revs
revs="$(approved_revisions "$@")"
if [ "$(printf '%s\n' "$revs" | grep -c .)" -eq 1 ]; then
printf '%s' "$revs"
else
printf '(%s)' "$(printf '%s\n' "$revs" | paste -sd'|' -)"
fi
}

# ref_matches_approved <ref> <signer> <pin> <candidate> → success when <ref> names exactly the
# approved revisions, each once, in any order. A single revision may be grouped or bare; several
# must be grouped, because an ungrouped `a|b` inside the anchored subject splits the whole regex
# into two alternatives and admits far more than either revision. Anything else fails, including
# a ref that adds, omits or repeats a revision.
ref_matches_approved() {
local ref="$1" body got want
shift
case "$ref" in
'('*')') body="${ref#\(}"; body="${body%\)}" ;;
*'|'*) return 1 ;;
*) body="$ref" ;;
esac
got="$(printf '%s\n' "$body" | tr '|' '\n')"
! grep -qvxE '[0-9a-f]{40}' <<<"$got" || return 1
[ "$(printf '%s\n' "$got" | sort | uniq -d)" = "" ] || return 1
want="$(approved_revisions "$@" | sort)"
[ "$(printf '%s\n' "$got" | sort)" = "$want" ]
}

# consumer → "workflow<TAB>signer<TAB>pin"
Expand Down Expand Up @@ -100,7 +133,7 @@ while IFS= read -r row; do
fi
[ -z "$(lookup "$approved" "$consumer")" ] || refuse "approved set names $consumer twice"
: "$applied_tag" "$applied_digest"
approved="${approved}${consumer}"$'\t'"${workflow}"$'\t'"${signer}"$'\t'"${pin}"$'\n'
approved="${approved}${consumer}"$'\t'"${workflow}"$'\t'"${signer}"$'\t'"${pin}"$'\t'"${candidate}"$'\n'
done <"$APPROVED_SET"

# Exactly the registered consumers, both directions: a missing row is a consumer nobody
Expand Down
67 changes: 39 additions & 28 deletions scripts/tests/test-publish-workflow-approved-revisions-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,6 @@ first_consumer="$(printf '%s\n' "$consumers" | sed -n 1p | cut -f1)"
first_workflow="$(printf '%s\n' "$consumers" | sed -n 1p | cut -f2)"
second_consumer='aws'
second_workflow="$(printf '%s\n' "$consumers" | awk -F'\t' -v c="$second_consumer" '$1 == c {print $2}')"
TRUSTED_RELEASE_STREAMS='ascoachingogvaner wedding-app'

is_trusted_release_stream() {
case " $TRUSTED_RELEASE_STREAMS " in
*" $1 "*) return 0 ;;
*) return 1 ;;
esac
}

# The artifact name is the repository name except for `.github`, which publishes as
# `github-config` — the same table the report uses.
Expand Down Expand Up @@ -183,6 +175,15 @@ EOF
# candidate_for <workflow> — the release_candidate_sha column: a commit for publish-app, `-` otherwise.
candidate_for() { if [ "$1" = publish-app ]; then printf '%s' "$SHA_E"; else printf '-'; fi; }

# set_for <repo> <workflow> — the consumer's canonical generated set, as write_set records it.
set_for() {
if [ "$2" = publish-app ]; then
printf '(%s|%s|%s)' "$(signer_for "$1")" "$SHA_B" "$SHA_E"
else
printf '(%s|%s)' "$(signer_for "$1")" "$SHA_B"
fi
}

# write_set <path> [<omit-repo>] [<extra-row>] — the approved set for every registered consumer.
write_set() {
local path="$1" omit="${2:-}" extra="${3:-}" repo workflow
Expand All @@ -195,9 +196,9 @@ write_set() {
[ -z "$extra" ] || printf '%s\n' "$extra" >>"$path"
}

# build_tree <name> <pattern|pair> — a complete fixture. `pair` means exact generated sets for
# bounded consumers and the immutable commit pattern for trusted release streams. Generic
# subjects always remain on the pattern form.
# build_tree <name> <pattern|pair> — a complete fixture. `pair` means every consumer carries its
# exact generated set: signer and pin, plus the release candidate for publish-app consumers.
# Generic subjects always remain on the pattern form.
build_tree() {
local name="$1" form="$2" root repo workflow ref
root="$WORK/$name"
Expand All @@ -207,13 +208,7 @@ build_tree() {
[ -n "$repo" ] || continue
case "$form" in
pattern) ref="$PATTERN" ;;
pair)
if is_trusted_release_stream "$repo"; then
ref="$PATTERN"
else
ref="($(signer_for "$repo")|$SHA_B)"
fi
;;
pair) ref="$(set_for "$repo" "$workflow")" ;;
*) printf 'build_tree: unknown form %s\n' "$form" >&2; exit 1 ;;
esac
write_consumer "$root" "$repo" "$workflow" "$ref"
Expand Down Expand Up @@ -261,7 +256,7 @@ root="$(build_tree off-pattern pattern)"
expect_pass 'switch off: every consumer on the pattern form passes' "$root" 0

root="$(build_tree off-pair pair)"
expect_pass 'switch off: bounded consumers use generated pairs and trusted streams use the pattern' "$root" 0
expect_pass 'switch off: every consumer, application tenants included, uses its generated set' "$root" 0

root="$(build_tree off-pair-reversed pair)"
write_consumer "$root" "$first_consumer" "$first_workflow" "($SHA_B|$(signer_for "$first_consumer"))"
Expand All @@ -271,7 +266,7 @@ expect_pass 'switch off: the pair in the other order is the same set and passes'
root="$(build_tree off-foreign pattern)"
write_consumer "$root" "$first_consumer" "$first_workflow" "($(signer_for "$first_consumer")|$SHA_D)"
expect_refusal 'switch off: an alternation carrying a revision outside the pair is refused by consumer and ref' \
"$root" 0 "$first_consumer" "$SHA_D" 'not the generated pair'
"$root" 0 "$first_consumer" "$SHA_D" 'not the generated set'

root="$(build_tree off-single-foreign pattern)"
write_consumer "$root" "$first_consumer" "$first_workflow" "$SHA_D"
Expand All @@ -281,7 +276,7 @@ expect_refusal 'switch off: a single concrete revision outside the pair is refus
root="$(build_tree off-half pattern)"
write_consumer "$root" "$first_consumer" "$first_workflow" "$(signer_for "$first_consumer")"
expect_refusal 'switch off: one revision where the pair needs two is refused' \
"$root" 0 "$first_consumer" 'not the generated pair'
"$root" 0 "$first_consumer" 'not the generated set'

root="$(build_tree off-three pair)"
write_consumer "$root" "$first_consumer" "$first_workflow" "($(signer_for "$first_consumer")|$SHA_B|$SHA_D)"
Expand All @@ -294,12 +289,28 @@ write_consumer "$root" "$first_consumer" "$first_workflow" "($(signer_for "$seco
expect_refusal "switch off: another consumer's pair on $first_consumer is refused" \
"$root" 0 "$first_consumer" "$(signer_for "$second_consumer")"

# A concrete pair is still cryptographically narrow, but on a trusted tenant release stream it
# recreates the platform approval gate this boundary is designed to remove.
root="$(build_tree release-stream-pinned pair)"
# Application tenants are held to their generated set like every other consumer (#3917): the
# release candidate belongs in it, the pattern form is refused under enforcement, and a revision
# outside the set is refused for the tenant specifically.
root="$(build_tree tenant-reordered pair)"
write_consumer "$root" 'wedding-app' 'publish-app' "($SHA_E|$SHA_A|$SHA_B)"
expect_pass 'a tenant set in another order is the same set and passes' "$root" 1
root="$(build_tree tenant-no-candidate pair)"
write_consumer "$root" 'wedding-app' 'publish-app' "($SHA_A|$SHA_B)"
expect_refusal 'a trusted release stream pinned to a generated pair is refused' \
"$root" 1 'wedding-app' 'trusted release stream' "$PATTERN"
expect_refusal 'a tenant set missing its release candidate is refused' \
"$root" 1 'wedding-app' 'not the generated set' "$SHA_E"
root="$(build_tree tenant-foreign pair)"
write_consumer "$root" 'ascoachingogvaner' 'publish-app' "($SHA_A|$SHA_B|$SHA_D)"
expect_refusal 'a tenant revision outside its approved set is refused for that tenant' \
"$root" 1 'ascoachingogvaner' "$SHA_D" 'not the generated set'
root="$(build_tree tenant-pattern pair)"
write_consumer "$root" 'wedding-app' 'publish-app' "$PATTERN"
expect_refusal 'switch on: a tenant on the pattern form is refused' \
"$root" 1 'wedding-app' 'pattern form'
root="$(build_tree tenant-ungrouped pair)"
write_consumer "$root" 'wedding-app' 'publish-app' "$SHA_A|$SHA_B|$SHA_E"
expect_refusal 'an ungrouped alternation is refused: it would split the anchored subject regex' \
"$root" 0 'wedding-app' 'not the generated set'

root="$(build_tree off-tag pair)"
write_consumer "$root" "$first_consumer" "$first_workflow" 'refs/tags/v.+'
Expand Down Expand Up @@ -342,15 +353,15 @@ expect_pass 'signer == pin: the single revision in a group passes' "$root" 1
root="$(build_tree same-doubled pair)"; same_set "$root"
write_consumer "$root" "$first_consumer" "$first_workflow" "($SHA_B|$SHA_B)"
expect_refusal 'signer == pin: a doubled alternation is not the canonical set and is refused' \
"$root" 0 "$first_consumer" 'not the generated pair'
"$root" 0 "$first_consumer" 'not the generated set'
root="$(build_tree same-foreign pair)"; same_set "$root"
write_consumer "$root" "$first_consumer" "$first_workflow" "($SHA_B|$SHA_D)"
expect_refusal 'signer == pin: an alternation adding a foreign revision is refused' \
"$root" 1 "$first_consumer" "$SHA_D"

# ── switch ON: the pattern form is refused for a per-consumer subject ────────────────────────
root="$(build_tree on-pair pair)"
expect_pass 'switch on: bounded pairs and trusted release streams pass together' "$root" 1
expect_pass 'switch on: every consumer on its generated set passes' "$root" 1

root="$(build_tree on-pattern pair)"
write_consumer "$root" "$first_consumer" "$first_workflow" "$PATTERN"
Expand Down
Loading
Loading