From 04e036c60353b67b81899e64d5cce8a80388a8ea Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Sat, 21 Feb 2026 08:05:16 -0800 Subject: [PATCH] Fold enforce-tag-immutability into harden-zot-registry (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Removed `status: active` from `enforce-tag-immutability` card — its requirements are folded into the parent `harden-zot-registry` goal's `accessControl` configuration - Updated `harden-zot-registry` with three-tier access control spec (anonymous read, artifact-workloads read+create, admins full) - Added `artifact-workloads` group creation step to `register-zot-oidc-client` - Added service account context to `wire-ci-registry-auth` ## Rationale Tag immutability requires authentication to be meaningful. Without auth, everyone is anonymous and gets the same policy. Rather than client-side push checks, the registry enforces immutability server-side: CI gets `["read", "create"]` (no update/delete), so pushing an existing tag is rejected by zot itself. ## Test plan - [ ] `mise run docs-check-links` passes - [ ] `mise run docs-mikado` shows enforce-tag-immutability as resolved Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/235 --- docs/how-to/zot/enforce-tag-immutability.md | 34 ++++++--------------- docs/how-to/zot/harden-zot-registry.md | 13 ++++++-- docs/how-to/zot/register-zot-oidc-client.md | 10 ++++-- docs/how-to/zot/wire-ci-registry-auth.md | 4 ++- 4 files changed, 30 insertions(+), 31 deletions(-) diff --git a/docs/how-to/zot/enforce-tag-immutability.md b/docs/how-to/zot/enforce-tag-immutability.md index afb8435..a3c1f72 100644 --- a/docs/how-to/zot/enforce-tag-immutability.md +++ b/docs/how-to/zot/enforce-tag-immutability.md @@ -1,7 +1,6 @@ --- title: Enforce Tag Immutability -modified: 2026-02-20 -status: active +modified: 2026-02-21 tags: - how-to - zot @@ -12,33 +11,18 @@ tags: Prevent accidental overwrite of version tags during CI push. -## Approach +## Resolution -Push-side enforcement: before pushing a version tag, query `GET /v2/blumeops//tags/list` and fail if the tag already exists. Commit SHA tags are inherently unique and skip this check. +Tag immutability is enforced server-side via `accessControl` policies in [[harden-zot-registry]], not by client-side push checks. The three-tier access model makes push-side enforcement unnecessary: -## Two Push Paths to Update +- **Anonymous:** `["read"]` — pull only, no push at all +- **`artifact-workloads` group (CI):** `["read", "create"]` — can push new tags but cannot overwrite or delete existing ones +- **Admins:** `["read", "create", "delete"]` — break-glass for removing bad images -1. **Dagger path:** Add tag-existence check before `ctr.publish()` in `.dagger/src/blumeops_ci/main.py` -2. **Nix/skopeo path:** Add tag-existence check before `skopeo copy` in `.forgejo/workflows/build-container-nix.yaml` +Since CI only has `create` (not `update`), pushing an existing version tag is rejected by zot itself. Commit SHA tags are inherently unique and never collide. -## Key Files - -| File | Purpose | -|------|---------| -| `.dagger/src/blumeops_ci/main.py` | Add pre-publish tag check | -| `.forgejo/workflows/build-container-nix.yaml` | Add pre-copy tag check | - -## Notes - -- After auth is enabled, the tag check API call may need auth too — or it can rely on anonymous read access from `anonymousPolicy: ["read"]` -- Only version tags (e.g., `v1.2.0`) need the check; commit SHA tags are unique by nature - -## Verification - -- [ ] Pushing a new version tag succeeds -- [ ] Pushing an existing version tag fails with a clear error -- [ ] Pushing a commit SHA tag always succeeds +This approach requires authentication to be meaningful — without auth, everyone is anonymous. The requirements are therefore part of the root [[harden-zot-registry]] goal's `accessControl` configuration. ## Related -- [[harden-zot-registry]] — Parent goal +- [[harden-zot-registry]] — Parent goal (includes this requirement) diff --git a/docs/how-to/zot/harden-zot-registry.md b/docs/how-to/zot/harden-zot-registry.md index 58d21dd..8c1f08a 100644 --- a/docs/how-to/zot/harden-zot-registry.md +++ b/docs/how-to/zot/harden-zot-registry.md @@ -1,6 +1,6 @@ --- title: Harden Zot Registry -modified: 2026-02-20 +modified: 2026-02-21 status: active requires: - register-zot-oidc-client @@ -32,9 +32,14 @@ Update `ansible/roles/zot/templates/config.json.j2` to add: 1. **`http.auth.openid`** — OIDC provider pointing to Authentik 2. **`http.auth.apikey: true`** — enable API key generation for CI -3. **`accessControl`** — anonymous read, authenticated write +3. **`accessControl`** — three-tier policy enforcing tag immutability: + - `anonymousPolicy: ["read"]` — anyone can pull + - `artifact-workloads` group: `["read", "create"]` — CI can push new tags but cannot overwrite or delete (immutable tags) + - admins group: `["read", "create", "delete"]` — break-glass for removing bad images 4. **`externalUrl`** — `https://registry.ops.eblu.me` for OIDC callback redirects +The `artifact-workloads` group must be created in Authentik (see [[register-zot-oidc-client]]) and a service account added to it for CI use. + ## Key Files | File | Purpose | @@ -49,6 +54,8 @@ Update `ansible/roles/zot/templates/config.json.j2` to add: - [ ] Unauthenticated push fails (401) - [ ] OIDC browser login works (redirect to Authentik and back) - [ ] API key push works (`docker login` with `zak_...` token) +- [ ] Pushing an existing version tag as CI user fails (no update permission) +- [ ] Admin can delete a tag if needed - [ ] Pull-through caching still works - [ ] `mise run services-check` passes @@ -56,6 +63,6 @@ Update `ansible/roles/zot/templates/config.json.j2` to add: - [[register-zot-oidc-client]] — Prereq: register OIDC client in Authentik - [[wire-ci-registry-auth]] — Prereq: update CI push paths with credentials -- [[enforce-tag-immutability]] — Prereq: prevent version tag overwrites +- [[enforce-tag-immutability]] — Folded into this card (server-side via accessControl) - [[adopt-commit-based-container-tags]] — Prereq: commit-SHA-based image tags - [[agent-change-process]] — C2 methodology diff --git a/docs/how-to/zot/register-zot-oidc-client.md b/docs/how-to/zot/register-zot-oidc-client.md index 3e8c891..4905e4c 100644 --- a/docs/how-to/zot/register-zot-oidc-client.md +++ b/docs/how-to/zot/register-zot-oidc-client.md @@ -1,6 +1,6 @@ --- title: Register Zot OIDC Client -modified: 2026-02-20 +modified: 2026-02-21 status: active tags: - how-to @@ -30,11 +30,16 @@ Register a zot OAuth2 provider and application in Authentik via blueprint, follo - New template `ansible/roles/zot/templates/oidc-credentials.json.j2` containing `client_id` and `client_secret` - Source `client_secret` from 1Password via a new pre_task in `ansible/playbooks/indri.yml` +6. **Create `artifact-workloads` group** in Authentik blueprint: + - Add a group resource to the blueprint with name `artifact-workloads` + - Create a service account user in the `artifact-workloads` group for CI push operations + - This group gets `["read", "create"]` in zot's `accessControl` (no update/delete — enforces tag immutability) + ## Key Files | File | Purpose | |------|---------| -| `argocd/manifests/authentik/configmap-blueprint.yaml` | Add zot blueprint (provider + app + policy) | +| `argocd/manifests/authentik/configmap-blueprint.yaml` | Add zot blueprint (provider + app + policy + group) | | `argocd/manifests/authentik/external-secret.yaml` | Add `AUTHENTIK_ZOT_CLIENT_SECRET` env var | | `ansible/roles/zot/templates/oidc-credentials.json.j2` | New: OIDC credentials for zot | | `ansible/playbooks/indri.yml` | New pre_task for zot OIDC client secret | @@ -44,6 +49,7 @@ Register a zot OAuth2 provider and application in Authentik via blueprint, follo - [ ] Authentik admin UI shows zot application - [ ] OIDC discovery endpoint includes zot client - [ ] Blueprint status is `successful` (check via API, not just logs) +- [ ] `artifact-workloads` group exists with CI service account ## Related diff --git a/docs/how-to/zot/wire-ci-registry-auth.md b/docs/how-to/zot/wire-ci-registry-auth.md index ad19d00..20d9827 100644 --- a/docs/how-to/zot/wire-ci-registry-auth.md +++ b/docs/how-to/zot/wire-ci-registry-auth.md @@ -1,6 +1,6 @@ --- title: Wire CI Registry Auth -modified: 2026-02-20 +modified: 2026-02-21 status: active tags: - how-to @@ -23,6 +23,8 @@ There are two push paths to update: > **Note:** The API key must be generated manually after OIDC login is working — log in to zot UI via browser, generate an API key, and store it in 1Password. This is a manual step between [[register-zot-oidc-client]] and this card, but not modeled as a formal `requires` dependency. +CI authenticates as a service account in the `artifact-workloads` group (created in [[register-zot-oidc-client]]). This group grants `["read", "create"]` — CI can push new tags but cannot overwrite or delete existing ones, enforcing tag immutability server-side. + ## Secret Flow ### Indri runner (minikube)