Skip to content

Instantly share code, notes, and snippets.

@manzil-infinity180
Last active July 20, 2026 15:57
Show Gist options
  • Select an option

  • Save manzil-infinity180/45f5ef9242bad461ed4682e0d3f9fbe2 to your computer and use it in GitHub Desktop.

Select an option

Save manzil-infinity180/45f5ef9242bad461ed4682e0d3f9fbe2 to your computer and use it in GitHub Desktop.

Registry Publish — Code Review Guide

PR #7015 · branch registry-container-entity · for the Monday review with Cole.

Read top-to-bottom. Every claim below is anchored to file:line so you can open it live on the call.


0. Open the review the right way

The PR says 78 files. Say this first, before anyone forms an opinion:

GENERATED    33 files, +15,756   ← entc + gqlgen output, nobody reviews
HAND-WRITTEN 45 files,  +4,376   ← the actual feature

78% of the diff is machine-written. Adding one ent entity (ContainerRegistry) makes entc emit the whole CRUD surface, predicates, mutation types, GraphQL plumbing, and migration table. That cost is identical for any new entity in this codebase — it says nothing about the size of this feature.

.gitattributes marks those paths linguist-generated=true, so GitHub collapses them by default and excludes them from language stats. Ask Cole to review with that on: he sees 45 files.

The hand-written 4,376 lines:

Area Files Lines Where to spend review time
pkg/registrypublish — core library 8 +1,502 Yes — the real logic
GraphQL resolvers + tests 6 +1,112 Yes — authz surface
Web UI 7 +752 Skim
Workflow wiring 5 +424 Yes — the injection seams
Platform seams (policyverify, subscription) 3 +217 Yes — small but load-bearing
Schema, deps, repo config 8 +208 Skim
ent schema (source of the generated code) 1 +135 Yes — read this first
Tenancy + authz plumbing 7 +26 Explain and move on

That last row is the answer to "why did you touch lint_systemviewer.go / closure_test.go / systemviewer_elimination_test.go?" — seven files, twenty-six lines, almost all one-line counter updates. They are not feature work. This repo is deliberately built so you cannot add a tenant-owned entity without updating them; CI fails otherwise. Their presence is evidence the feature was integrated correctly.


1. What the feature does (the 60-second version)

Cole's requirement: all generated attestations must be stored in and retrievable from JFrog Artifactory.

  • Store — when an attestation is ingested, the platform pushes the DSSE envelope into the customer's registry, next to the image it describes, in cosign's layout: tag sha256-<image-digest>.att, layer media type application/vnd.dsse.envelope.v1+json.
  • Retrieve + verifyverifyAttestationFromRegistry pulls the envelope back out of the registry and evaluates it against the product's policy. Nothing is re-stored; only the verdict is persisted. The registry is the source of truth.
  • Gate — a registry can be marked require_policy_pass, in which case the platform refuses to publish an attestation that did not pass its product's policy. Fail-closed.

Interop matters: because the layout is cosign's, cosign verify-attestation, crane, and JFrog's own UI can read what we write. We have no cosign dependency — the library is Google's go-containerregistry. We implement the convention, exactly as witness attach attestation does.


2. The data model — start here

judge-api/ent/schema/container_registry.go (+135) — the only hand-written ent file, and the source of all 15.7k generated lines.

Two mixins (:31-38):

// Provides the required, immutable tenant edge plus the tenant
// isolation privacy rules (query filtering + mutation ownership),
// so this schema needs no Policy() of its own.
TenantMixin{},
MetaDataMixin{},

TenantMixin is the whole tenancy story in one embed. It adds a required, immutable tenant edge and a full privacy policy (ent/schema/tenant_mixin.go:15-41): mutations go through DenyIfNoViewer → DenyMutationIfTenantSuspended → AllowMutationIfOwnerTenant → DenyMutationIfNotAccessibleTenant; queries through DenyIfNoViewer → DenyIfNoTenants → FilterAccessibleTenants.

15 fields. The ones worth naming on the call:

Field Notes
url bare host, no scheme — SSRF-validated on write
repo_key JFrog-style path segment; a security boundary, see §5
password, token Sensitive() — omitted from String() and never returned by GraphQL
predicate_filters JSON []string, which predicate types to publish
sync_enabled opt-in, default false
require_policy_pass the fail-closed gate, default false
revoked soft delete

One partial unique index (:123-131):

index.Fields("name").Edges("tenant").Unique().
    Annotations(entsql.IndexWhere("NOT revoked"))

Unique on (name, tenant) only over non-revoked rows — a disconnected registry keeps its history without blocking the name for re-registration.

Known, documented risk to raise yourself: the schema comment at :22-25 states credentials are plaintext at rest, relying on Postgres TDE + KMS-managed disk encryption, matching how ComplianceIntegration already works. That's a deliberate risk acceptance consistent with the existing codebase — say it before Cole finds it.


3. The generated tenancy machinery — explain, don't defend

One schema file fans out through two generators:

  • entc (judge-api/ent/entc.go) → ent/generated/**
  • tenantgen (judge-api/ent/internal/tenantgen/, an in-tree entc extension) → the tenant-isolation files

Everything regenerates with jade generate --go.

File Hand-written? Generated by Why it changed
ent/schema/container_registry.go YES the source of truth
ent/generated/** (18 files) no entc new entity → full CRUD/GraphQL surface
tenantinvariant/register_gen.go + triggers_gen.sql no tenantgen has an M2O tenant edge
rule/tenant_filter_gen.go no tenantgen query-side tenant filter
tenantscope/constructors_gen.go no tenantgen has TenantMixin
tenantclosure/closure.go YES (1 line) AST completeness test fails otherwise
tenantclosure/closure_test.go YES (1 line) pinned count 116 → 117
rule/tenant_mutation_accessor_gate_test.go YES (1 line) mutation-accessor reflection check
auth/systemviewer_elimination_test.go YES (1 line) 29 → 32see §9
pkg/judgeclient/generated.go no genqlient +21 lines, regenerated from the schema

Answers to your specific questions:

ent/generated/containerregistry/containerregistry.go — machine-generated (line 1 literally says // Code generated by ent, DO NOT EDIT.). The containerregistry/ sub-package is metadata + predicates: column-name constants (FieldPassword = "password"), enum validators, order-by builders, and typed predicates (containerregistry.HasTenantWith(...)). The ent/generated/containerregistry_*.go files in the parent package hold the runtime: the struct and the Create/Update/Delete/Query builders. The split exists to break an import cycle — builders import the sub-package, never the reverse. Sub-package = "how to name and filter columns"; parent = "how to read and write rows."

tenantclosure/closure_test.go — you asked why the SBOM comment was removed. The +N suffix is a rolling annotation naming only the most recent PR's delta, not cumulative history:

- ClassTenantMixin: 116, // +2: SBOMComponentInterval + SBOMScan (...)
+ ClassTenantMixin: 117, // +1: ContainerRegistry (registry-publish connector)

Nothing was lost — we replaced the previous PR's note with ours, as the file's convention requires. "Tenant closure" is the transitive set of rows belonging to one tenant, and this manifest is the single source of truth for tenant export (#6176) and GDPR erasure (#5503). An AST test fails the build if a schema is added without a manifest entry, so a new entity can't silently escape either.

tenantinvariant/register_gen.go + triggers_gen.sql — a "tenant invariant" is one rule: once a row's tenant FK is non-NULL it can never change. Two enforcement layers: an ent mutation hook (covers ent traffic) and Postgres triggers (catch raw SQL the hook can't see). Our 22 lines add judge_tenant_immutable__container_registries() + a BEFORE UPDATE trigger that raises on both NULL-ing and cross-tenant reassignment, while still allowing NULL → non-NULL for backfills. From #4937 — closes the raw-SQL re-tenanting bypass.

rule/tenant_filter_gen.go — this is what makes TenantMixin's query policy real. Our three lines:

case *ent.ContainerRegistryQuery:
    q.Where(containerregistry.HasTenantWith(tenant.Or(orPredicates...)))
    return true

Every read is silently rewritten to WHERE tenant IN (viewer's tenants).

tenantscope.NewContainerRegistryCreate — you asked what this is:

func NewContainerRegistryCreate(c *generated.ContainerRegistryClient, scope TenantScope) *generated.ContainerRegistryCreate {
	return c.Create().SetTenantID(scope.TenantID())
}

TenantScope is an opaque struct with unexported fields, obtainable only via tenantscope.New(ctx, tenantID) (verifies the viewer is a member) or NewAsAdmin (requires platform admin). Raw .SetTenantID is banned by a forbidigo rule (.golangci.yml:272) because a function taking a bare uuid.UUID "has no way to express 'the caller has verified the viewer can act on behalf of this tenant'" — two real in-house CVEs (#4940 cross-tenant attestation migration, #4941 cross-tenant comment add) had exactly that shape. Wrapping the UUID in a type only producible by a verification step makes cross-tenant writes compile-impossible. This PR adds no new forbidigo exclusions — worth pointing out.


4. Runtime flow — ingest to JFrog

This is the spine of the walkthrough. Follow it in order.

cilock run --enable-archivista
        │  uploads DSSE to Archivista (no curl, user's login session)
        ▼
Archivista stores envelope → emits pub/sub message {gitoid, apiToken}
        ▼
cmd/server/cmd/api.go:2370   ProcessMessage
        ▼
pkg/subscription/subscription.go:114   GenerateWorkflows → processAttestation
        │  NEW: initRegistryPublishWorkflow (:1322)
        │  ├─ row mode: registrypublish.Matcher() under a 2-min timeout
        │  └─ env fallback: only when row mode isn't wired at all
        ▼  returns WorkflowInfo{Name:"RegistryPublishWorkflow", Input:…}
api.go:2410   ScheduleWorkflow (durabletask)
        ▼
pkg/workflow/workflows/registrypublish/publish.go:42   PublishWorkflow
        ├─ activity 1: ArchivistaRetrieveActivity  → envelope returned INLINE
        └─ activity 2: RegistryPublishActivity
        ▼
pkg/workflow/activities/registrypublish/publish.go:42
        ├─ registrypublish.LoadAuth(ctx, registryID)   ← creds loaded BY ID here
        └─ registrypublish.PublishEnvelope(...)
        ▼
JFrog:  <repo>:sha256-<image-digest>.att

subscription.go fails closed (:1326-1336): if the matcher errors, we return nil and publish nothing. Falling back to env credentials there would bypass predicate filters, require_policy_pass, and revocation — and could ship global credentials to a host named by tenant-controlled attestation subjects.

Why the envelope is returned inline (activities/archivista/retrieve.go:44-47): an earlier design wrote /tmp/<sha> and broke when durabletask dispatched the next activity to a different pod.

Retry / idempotency: 3 attempts, 2s initial, 2× backoff, 30s cap (pkg/workflow/retry.go:22-27). Retry safety comes from the push itself — PublishEnvelope skips a layer digest that's already present, so a replay is a no-op.

The injection seams — why they exist

pkg/workflow/registration/registrypublish.go installs two closures: SetMatcher and SetAuthLoader. Expect Cole to ask why.

The depguard rule activities-use-graphql-not-ent (.golangci.yml:191-215) forbids anything under pkg/workflow/activities/** from importing ent/generated, because direct ent access bypasses the tenant privacy filter. The normal escape hatch — read over GraphQL with a tenant-scoped token — doesn't work here, because the credential columns are Sensitive() and GraphQL never returns them. So the registration layer (which legitimately holds deps.EntClient and is outside that glob) captures the client in a closure and the activity stays ent-free. Precedent in the same file: cilock.SetPlatformRootsFn, byorextract.SetPersistDraftFn.

Why deps.ArchivistaStore — your question. The policy gate must (a) download the just-ingested envelope to seed an in-memory source and (b) hand a Store to policyverify.RunSync. Neither can live in the activity (ent-barred), and the gate is fundamentally an ent query chain. nil disables the gate; for a require_policy_pass registry that means no publish at all — fail-closed.

Why credentials load by ID at push time, not from workflow input: durabletask persists workflow and activity inputs to the orchestration history store. Credentials in the input would be written to durable storage and replayed. Loading by ID also re-checks Revoked(false), so a queued or retried workflow stops publishing the moment the owner disconnects the registry.

The policy gate

AttestationPassesPolicy (registration/registrypublish.go:143), fail-closed at every step:

git attestor subject  .../git/v0.1/remote:<url>
        ▼  (both raw and .git-stripped forms)
ProductRepository.url  ──► Product  ──► PolicyBinding  ──► RunSync
                              (all scoped to tenant.ID(tenantID))

Two subtleties that were real bugs:

  1. It downloads this envelope by gitoid and seeds a MemorySource, rather than querying Archivista's subject-digest index — the index lags the ingest-time dispatch, which made the gate fail-closed on attestations that should pass.
  2. RunSync runs under sysCtx, not the raw ingest ctx — the ingest subscriber carries no viewer, so the binding/policy load was privacy-denied.

5. pkg/registrypublish — the core library

The biggest hand-written chunk (+1,502). Three files.

registrypublish.go

TargetsFromSubjects (:151) — turns attestation subjects into publish targets. Handles two conventions:

  • cosign/SLSA: subject.Name is the image ref, digest is the image digest.
  • witness/cilock: the docker attestor emits URI-named subjects — .../docker/v0.1/imagereference:<ref> and .../imagedigest:<digest>.

Deliberate constraint: it pairs refs to a digest only when there is exactly one digest (:183). One docker build emits one digest and N refs (multiple -t tags), so fan-out is correct; with several distinct digests, flat subjects can't safely pair repo↔digest, so those are dropped rather than risk attaching an attestation to the wrong image.

PublishEnvelope (:299) — read-append-write:

  1. targetTagname.NewRepository(...).Tag("sha256-<hex>.att")
  2. tagLocks.lock(tag) — in-process serialization
  3. remote.Image(tag) — if a layer with our digest already exists, return AlreadyPresent (idempotency); 404 → start from empty.Image
  4. mutate.Append(base, mutate.Addendum{Layer: static.NewLayer(json, DsseMediaType), ...})
  5. remote.Write(tag, img), then loop to re-verify survival (max 4 attempts)

FetchEnvelopes (:375) — the read side. Three caps, because a registry is external input: 256 layers, 64 MiB per layer, and 256 MiB aggregate — the first two alone multiply out to 16 GiB of live bytes, enough to OOM a multi-tenant service. readLayerCapped errors rather than truncates: a truncated DSSE envelope must never be treated as the attestation's content.

What we write (the cosign layout):

Element Value Whose convention
tag sha256-<image-digest>.att cosign
layer media type application/vnd.dsse.envelope.v1+json cosign
annotation dev.witnessproject.witness/signature: "" witness
manifest/config OCIManifestSchema1 / OCIConfigJSON OCI

go-containerregistry APIs used

Reference: https://pkg.go.dev/github.com/google/go-containerregistry

API Where What it does
name.NewRepository, .Tag() :359, :363 parse repo, derive the .att tag
name.ParseReference :262 parse a subject name to extract its repo
remote.Image :315, :381 GET the manifest at the tag (404 = nothing yet)
empty.Image :522 zero-layer base
mutate.MediaType / ConfigMediaType :522-523 stamp OCI (not Docker) types
static.NewLayer :334 wrap in-memory bytes as a layer — no tarball/gzip
mutate.Append :333 functional append, returns a new image
remote.Write :341 upload blobs, PUT the manifest
authn.Basic / Bearer / Anonymous :510-514 credential providers

Note remoteOptions' default branch (:513): a target with no stored credentials gets authn.Anonymous, never the ambient keychain — which would hand the server's own docker credentials to whatever host a tenant-controlled subject names.

registry.go

MatchTargets (:61) — pure and testable; the ent-backed matcher resolves the registry list and delegates the decision here.

registryMatchesRepository (:127) — the security-relevant one. The repository string comes from the attestation subject, i.e. from whoever produced the evidence. Host-only matching would let a subject naming host/some-other-namespace/img drive this registry's stored credentials at a namespace the tenant never registered. So when repo_key is set it's a boundary: HasPrefix(repository, url+"/"+repoKey+"/"). Empty repo_key keeps host-only matching — correct for Docker Hub and ghcr, which have no such concept.

hostguard.go — SSRF defence

Registry URLs are tenant-controlled input. Without this, a tenant admin could point a "registry" at loopback, RFC1918, link-local (cloud metadata), or CGNAT and drive server-side requests into the internal network.

Two layers, both fail-closed unless REGISTRY_PUBLISH_ALLOW_PRIVATE_HOSTS:

  • ValidateRegistryURL (:39) — registration-time check. Explicitly the non-authoritative layer; it exists to give the operator a clear error early.
  • registryTransport (:76) — the authoritative one. A net.Dialer.Control hook checks the resolved address of every connection, which also defeats DNS rebinding. And t.Proxy = nil (:91) — http.DefaultTransport carries ProxyFromEnvironment, and with HTTPS_PROXY set the dial would target the proxy while the proxy resolves and connects to the tenant-supplied host, bypassing the whole guard.

6. The verify path — "retrievable from JFrog"

judge-api/container_registry_verify.go. This is the file to walk when Cole asks how retrieval is proven.

:91   DownloadDSSE(gitoid)        ← platform's own copy: subjects only
:100  MatchTargets                 ← which repo + digest
:120  FetchEnvelopes(target, auth) ← THE RETRIEVE, straight from JFrog
:129  MemorySource                 ← seeded ONLY with the requested envelope
:153  policyverify.RunSync(..., WithCollectionSource(memSource))

We did not write a second verifier. RunSync is the platform's existing policy-evaluation entrypoint (pkg/policyverify/sync.go:75, dating to 2026-04-29, ~2.5 months before this feature), delegating to witness's verifier in pkg/workflow/activities/cilock/verify.go (2024-08-01). Our total contribution inside it is 38 linesrunConfig, RunOption, WithCollectionSource — threaded into a variable that already existed:

var collectionSource source.Sourcer = NewEntSource(client, store, granter, bind.Edges.Tenant.ID)
if cfg.collectionSource != nil { collectionSource = cfg.collectionSource }

So the substitution is where the evidence comes from, not how it's judged. Same policy engine, same signature checks, same verdict row — different input. There are three RunSync callers now: the original VerifyComplianceSync resolver, our registry verify, and our publish gate.

Why only the requested envelope is loaded (:110-116) — this was a real soundness bug. The .att tag can hold several attestations for the same image, and witness passes a step as soon as any collection satisfies it (policy/step.go: if len(r.Passed) > 0 { pass = true }). Loading siblings would let a different, trusted envelope produce PASSED for an attestation that is itself untrusted. Zero loaded → hard error saying the registry copy is missing or altered.

envelopesEquivalent (:161) compares payload type + payload bytes + at least one matching (keyID, signature) pair. Raw-JSON byte equality is deliberately not used, because the publish path re-marshals the envelope — field ordering can differ while the signed content is identical.

Empirical proof to offer Cole: an attestation present in Archivista but absent from JFrog made this path error; the ones present in JFrog passed. That's the difference between "we verified it" and "we verified it from the registry."


7. API surface + authorization

Six mutations. Note the ID casing — registryID, bindingID, tenantID — matching the repo convention (tenantID appears 47×, productID 5×):

Mutation Impl Scope
registerContainerRegistry container_registry_resolvers.go:37 SupplyChainWrite
updateContainerRegistry :109 SupplyChainWrite
setContainerRegistrySyncEnabled :~185 SupplyChainWrite
deleteContainerRegistry :~210 SupplyChainAdmin (drops a stored credential)
attachAttestationToRegistry container_registry_attach.go:41 SupplyChainWrite
verifyAttestationFromRegistry container_registry_verify.go:37 AttestationVerify

Registered in internal/auth/graphql_scope.go:391-401.

Two independent layers — worth stating explicitly, they're often confused:

  1. GraphQL scope — token-level. Does this API token carry SupplyChainWrite?
  2. ent privacy — row-level. TenantMixin's policy + tenant_filter_gen.go rewrite every query to the viewer's tenants.

Plus a resolver-level check, requireTenantOwnerOrAdminFor, and adminctx.AsAdmin used to load a row across the tenant filter after authorization has been established (not instead of it).

Tests to show

These are the ones worth opening on a security-focused review:

Test What it proves
TestContainerRegistry_TenantIsolation tenant A cannot see B's registries
TestContainerRegistry_CredentialsAreSensitive credentials never leave via the API
TestRegisterContainerRegistry_AuthzCrossTenant cross-tenant create is refused
TestAttachAttestationToRegistry_Authz cross-tenant attach is refused
TestUpdateContainerRegistryClearsCredentialsOnRetarget moving a URL drops the old secret
TestContainerRegistry_UniqueNamePerTenant the partial unique index behaves
TestKeyedMutexEvictsEntries / …WithWaiter the publish lock doesn't leak
TestRegistryTransportDoesNotProxy SSRF guard can't be proxied around

8. Config, UI, and the small stuff

internal/configuration/envvars.go (+9) — Registry is the canonical, single-source list of every environment variable judge-api consumes; the daemon and the secrets UI read it at runtime. Sensitive: true marks a value to be masked in that UI; Category groups it. Our 7 entries are the REGISTRY_PUBLISH_* set for the env-var POC fallback — the mode used before per-tenant rows existed. Normal operation uses registry rows; the env mode is only reached when row mode isn't wired at all.

pkg/judgeclient/generated.go (+21) — genqlient output, regenerated from the schema. Not hand-written.

jade/cmd/lint_systemviewer.go (+1) — an allowlist for the SystemViewer ratchet lint: "judge-api/pkg/workflow/registration/registrypublish.go": 3. See §9.

.gitattributes (+15) — linguist-generated=true tells GitHub two things: collapse these files in the diff view, and exclude them from repository language statistics. Hand-written schema sources (mutations.graphql, *_extended.graphql, policy_*.graphql) are deliberately not listed. This is what turns a 78-file PR into a 45-file review.

web/.../ContainerRegistriesCard.tsx (+490) — Settings → Connectors card. Queries the tenant's rows, add-form, per-row sync and require-policy toggles, delete, and the inline predicate-filter editor (presets + custom add + removable chips) so filters can be changed without disconnecting and re-adding.

One gotcha worth knowing because it cost real debugging time: ToggleInput must be wrapped in an ancestor <label>. Without it the toggle renders, looks live, and does nothing — no network request at all (issue #6760). That was the "toggles don't work" symptom.


9. Raise these yourself before Cole finds them

Going in with these is much stronger than being caught by them.

9a. We reversed a one-way ratchet

internal/auth/systemviewer_elimination_test.go:36SystemViewerSitesRemaining went 29 → 32. That constant is the countdown for an epic to delete SystemViewer entirely, and the file says:

"Every mint-site migration PR MUST LOWER it… It can therefore only ever go DOWN."

Its failure message names the alternative: "mint a per-tenant scoped token via internal/auth/internalgrant instead."

Our 3 sites are all in pkg/workflow/registration/registrypublish.go:49 matcher, :112 auth loader, :147 policy gate. The justification: the ingest subscriber context carries no viewer, and the credential columns are Sensitive() so GraphQL can't serve them. We compensate with explicit tenant.ID(tenantID) predicates on every query.

The weak spot: internalgrant — the prescribed alternative — is already used in this same feature (RunSync takes a *internalgrant.Granter). So "we couldn't" isn't quite true; "we didn't attempt it" is. Offer it as a follow-up.

9b. A widened privacy bypass

pkg/policyverify/sync.go:684-692persistEvaluation now saves under privacy.DecisionContext(ctx, privacy.Allow). The Status mutation policy admits only a SystemViewer, so a user-triggered verify succeeded at verification but was denied at persist. The fix is justified by the authorization already performed upstream, but it widens the bypass for every RunSync caller, not just ours. Narrowing it to the registry-verify path is a small, clean follow-up.

9c. Cross-replica publish race (open)

Two Judge replicas can both read manifest M0, each append their own layer, each observe their own write succeed, and the later write silently drops the earlier layer. No error anywhere.

This is not our bug to have invented — it's inherent to the cosign tag layout. The OCI project says so about exactly this mechanism:

"The client requirement to manage the fallback tag is subject to race conditions since the existing content of the fallback tag must first be queried to add the new entry" — https://opencontainers.org/posts/blog/2024-03-13-image-and-distribution-1-1/

OCI tags have no compare-and-swap — no If-Match on a manifest PUT — so read-append-write cannot be made atomic client-side. witness attach attestation has the same exposure.

Mitigation shipped: publishes to one tag are serialized within a process (keyedMutex, reference-counted so it can't leak). Single-replica deployments — including the demo standalone — are fully covered.

The real fix is the OCI Referrers API (OCI 1.1): each attestation is pushed as its own manifest with a subject field pointing at the image digest, so every push is digest-addressed and nothing can be overwritten. JFrog supports it from Artifactory 7.90.1 (https://jfrog.com/help/r/jfrog-artifactory-documentation/use-referrers-rest-api-to-discover-oci-references). Interim option: a pg_advisory_lock keyed on the tag, which closes it for all Judge-originated writes.

9d. Not ours

If CI is red, check whether it's TestTriggerComplianceSync_RejectsConcurrentSyncs. That test fails identically on origin/main — a compliance-sync test with a 30-minute staleness boundary. Verified by running it in a worktree at origin/main.


10. Live demo

Scripts are in the rahulxf-swf repo under demo/ (private):

export ADMIN_PASS=''
./demo/01-publish.sh v6        # build → attest → upload → platform pushes to JFrog
./demo/02-verify.sh  v6        # platform verify + offline crane/cilock verify
./demo/03-policy-gate.sh v7    # PASS publishes, BLOCKED does not

The gate demo is the strongest single artifact: same registry, same policy, one build with a git subject and one without — both images land in JFrog (docker pushes those), but only the attestation that passed its product's policy is published.

Vocabulary to keep straight on the call, because it caused confusion during development: v6/v7 are image tags, pushed by docker buildx, never gated. sha256-<digest>.att is the attestation tag, published by the platform, and it's the only thing the gate controls. Seeing an image tag in JFrog tells you nothing about whether its attestation was published.

Registry Publish — Guide

PR #7015 · branch registry-container-entity

Read this first, for yourself. Part 1 explains the words. Part 2 explains the flow (it follows your diagram panel by panel). Part 3 maps it to files. Part 4 is the "why did you do it that way" answers. Part 5 is what's still open.


PART 1 — The words, in plain English

You'll hear these on the call. Here's what each one actually means.

Attestation — a signed statement about a build. "This image was built from this git commit, by this pipeline, at this time." It's just JSON, signed.

in-toto — the standard that defines the shape of that JSON. It has a subject (what the statement is about) and a predicate (what's being claimed).

DSSE — the envelope the signed JSON sits inside. Think of it as: payload (base64 of the in-toto JSON) + signatures + key IDs. DSSE is the wrapper, in-toto is the letter inside.

gitoid — the platform's ID for one stored attestation. A sha256 hash of the envelope. When you see a 64-character hex string in our logs, that's usually a gitoid, not an image digest. These two get confused constantly.

Image digest — the sha256 the registry gives an image. Also 64 hex chars. Different thing from a gitoid.

OCI — the standard for container registries. JFrog, Docker Hub, ghcr, ECR all speak it. "OCI registry" = "container registry."

Archivista — where the platform stores attestations internally. Our database for evidence.

cosign layout — a convention for where to put an attestation in a registry: tag it sha256-<image-digest>.att in the same repo as the image. We follow this convention. We do not use cosign the tool — we have zero cosign dependency. We follow it so your existing tools can read what we write.

ent — the ORM this codebase uses. You write a schema file, it generates all the database code. This is why the PR has 15,000 lines you didn't write.

durabletask — the workflow engine. Work that must survive restarts (like "push this to a registry, retry if it fails") runs as a durable workflow.

Predicate type — what kind of attestation it is. e.g. https://slsa.dev/provenance, https://spdx.dev/Document (SBOM). Registries can filter on this.


PART 2 — The flow (follows your diagram)

Panel 1 — The big picture

One build → one signed attestation → stored in JFrog → pulled back and verified.

1. YOU RUN A BUILD
   cilock run -a git,docker -- docker buildx build --push -t <repo>:v6 .

   cilock wraps your build, watches it, and produces a signed attestation
   describing what happened. Docker pushes the image to JFrog as normal.

2. CILOCK UPLOADS THE ATTESTATION TO THE PLATFORM
   Not to JFrog — to us. It lands in Archivista and gets a gitoid.

3. THE PLATFORM PUSHES IT TO JFROG
   The platform looks at the attestation's subjects, works out which image
   it describes, and writes the envelope into JFrog next to that image.

4. LATER: PULL IT BACK AND VERIFY
   The platform (or you, with crane) pulls the envelope back OUT of JFrog
   and checks it against a policy.

The key invariant (your diagram calls it out, and it's worth saying aloud): the platform authenticates to JFrog with the token stored on the registry row — never with the operator's docker login. You can prove it in JFrog's UI: the "Deployed By" field shows the platform's account, not yours.

Panel 2 — The four verbs

Verb What happens
Publish platform writes the DSSE envelope to the registry
Store it lands at sha256-<digest>.att, cosign layout, beside the image
Retrieve FetchEnvelopes pulls it back (or plain crane does)
Verify in-memory → policy check → PASSED / FAILED

Plus an opt-in publish gate: require_policy_pass means publish only if the attestation passes the policy bound to its product. Fail-closed.

Panel 3 — Two ways an attestation reaches JFrog

Auto-sync — automatic, on ingest. Every matching attestation. Honours the predicate filter. Entry point: subscription.go → initRegistryPublishWorkflow.

Manual attach — you pick one attestation in the UI and push it. The filter is deliberately bypassed (you chose it explicitly). Entry point: container_registry_attach.go.

Both schedule the same workflow and push identically. The workflow carries only a registry ID — never a credential. The credential is loaded at push time. (Why that matters is in Part 4.)

Panel 3b — The publish gate

for each sync-enabled registry:
    gate OFF  →  publish (predicate filter only)
    gate ON   →  check policy:
                    git-remote subject → ProductRepository → Product → PolicyBinding
                    → RunSync
                 PASSED     → publish
                 not PASSED → skip (fail-closed)

"Fail-closed" means: if anything goes wrong — no git subject, no product, no policy, an error — we do not publish. Silence is not permission.

Panel 4 — How the platform knows which image

The publish target is derived from the attestation, never guessed. Two subject conventions are supported:

  • cosign / SLSA: subject.name is the image ref, subject.digest.sha256 is the image digest.
  • witness / cilock: separate URI-named subjects — .../imagereference:<ref> and .../imagedigest:<digest>.

Both feed TargetsFromSubjects(), which produces target = repo : sha256-<digest>.att.

Panel 5 — Verify from JFrog

1 RESOLVE    target (repo + digest) from the attestation's subjects
2 RETRIEVE   FetchEnvelopes(target, stored token) — pull the .att via remote.Image
3 IN MEMORY  MemorySource.LoadEnvelope(env) — nothing is re-stored
4 VERIFY     RunSync(bindingID, WithCollectionSource(mem)) — witness verifies vs policy
5 PERSIST    only the verdict. JFrog stays the source of truth.

The important bit: the verifier already accepted a pluggable evidence source. We feed it a source filled from JFrog. No changes to the verification engine itself.

Panel 6 — When we can't push (five gates)

An attestation publishes only if it clears all five:

  1. Has an image subject (ref + digest)? — no → non-image artifact
  2. Subject digest == registry manifest digest? — no → wrong digest / orphaned
  3. Image repo under the registry host? — no → different registry, skipped
  4. (auto-sync only) Predicate type matches the filter? — no → skipped
  5. Registry reachable + stored creds valid? — no → auth / network failure

All clear → published at repo:sha256-<digest>.att.


PART 3 — Where the code lives

The 78-file question

GENERATED    33 files, +15,756   ← ent + gqlgen wrote this, nobody reviews it
HAND-WRITTEN 45 files,  +4,376   ← the actual feature

Adding one ent entity generates ~15,700 lines: database builders, query predicates, GraphQL plumbing, migrations. It's the same cost for any new entity in this codebase. .gitattributes marks those paths linguist-generated=true so GitHub collapses them — with that on, the review is 45 files.

Area Files Lines Read it?
pkg/registrypublish — the core library 8 +1,502 yes
GraphQL resolvers + tests 6 +1,112 yes
Web UI 7 +752 skim
Workflow wiring 5 +424 yes
Platform seams 3 +217 yes
ent schema 1 +135 yes, first
Tenancy/authz plumbing 7 +26 explain, move on

The files that matter, in reading order

1. judge-api/ent/schema/container_registry.go (+135) The one hand-written database file. Describes what a "registry connection" is: name, url, repo_key, username/password/token, predicate filters, sync_enabled, require_policy_pass, revoked. Everything generated comes from here.

2. pkg/subscription/subscription.go (+65) Reacts to "an attestation was just ingested." Decides whether to schedule a publish. Fails closed if it can't decide.

3. pkg/workflow/registration/registrypublish.go (+262) Two injected functions: which registries match this attestation (the matcher) and what are this registry's credentials (the auth loader). Also the policy gate.

4. pkg/workflow/workflows/registrypublish/publish.go (+76) The durable workflow: retrieve envelope → push to registry. Retries 3×.

5. pkg/workflow/activities/registrypublish/publish.go (+79) The activity that actually pushes. Loads credentials by ID here.

6. pkg/registrypublish/registrypublish.go (+517) — the core TargetsFromSubjects (which image?), PublishEnvelope (write it), FetchEnvelopes (read it back).

7. pkg/registrypublish/registry.go (+177) MatchTargets — the pure matching decision, easy to unit test.

8. pkg/registrypublish/hostguard.go (+126) — security Stops someone pointing a "registry" at localhost or cloud metadata.

9. container_registry_verify.go (+193) The retrieve-and-verify resolver. Panel 5 in code.

10. container_registry_attach.go (+130) Manual attach. Panel 3, left side.

11. container_registry_resolvers.go (+273) Register / update / toggle sync / delete.

12. web/.../ContainerRegistriesCard.tsx (+490) The Settings → Connectors UI.

The plumbing files (your "why did this change?" list)

All seven together are 26 lines. They are counter and allowlist updates that this repo's guard-rail tests require when you add a tenant-owned entity. CI fails without them. They're evidence the feature was wired in correctly.

File What it is
tenantclosure/closure.go + _test.go a list of every entity and whether it belongs to a tenant — used by GDPR erasure and tenant export. We add one line + bump a count 116 → 117.
tenantinvariant/register_gen.go, triggers_gen.sql generated. Postgres triggers that stop a row's tenant ever being changed.
rule/tenant_filter_gen.go generated. Makes every database read automatically filter to your own tenant.
tenantscope/constructors_gen.go generated. A safe constructor so you can't set a tenant ID without proving you're allowed to.
rule/tenant_mutation_accessor_gate_test.go one line, a reflection check.
auth/systemviewer_elimination_test.go a counter, 29 → 32. See Part 5 — this one is contentious.
jade/cmd/lint_systemviewer.go one allowlist line, same topic.
pkg/judgeclient/generated.go generated by genqlient from the schema.

About that closure_test.go comment you asked about — the old line said // +2: SBOMComponentInterval + SBOMScan and now says // +1: ContainerRegistry. Nothing was lost. That comment is a rolling note naming the most recent PR's change only, by convention. We replaced the previous PR's note with ours, which is what you're supposed to do.


PART 4 — "Why did you do it that way?"

These are the questions most likely to come up.

Why cosign's layout instead of our own?

So your existing tools work. cosign verify-attestation, crane, and JFrog's own UI can all read it. If we invented a format, the evidence would only be readable by us — which defeats the point of "retrievable from JFrog."

Do we depend on cosign?

No. Zero cosign imports. The library we use is Google's go-containerregistry (the same one crane is built on). We implement cosign's convention, exactly as witness attach attestation does.

Why does the workflow carry a registry ID instead of the credential?

Because the workflow engine writes its inputs to durable storage and replays them on retry. A password in the workflow input would be persisted to disk and replayed. Loading by ID at push time also means we re-check the revoked flag — so if you disconnect a registry, a queued job stops publishing immediately instead of using a stale credential.

Why the odd "injected function" pattern (SetMatcher, SetAuthLoader)?

There's a lint rule: workflow activities may not touch the database directly, because that skips the tenant-isolation filter. The normal workaround is to read over our GraphQL API with a scoped token — but that can't work here, because the credential columns are marked Sensitive() and GraphQL never returns them. So the registration layer (which is allowed to hold a database client) passes in two small functions, and the activity stays database-free. The codebase already uses this pattern twice elsewhere.

Why does the policy gate need a git attestor?

That's the only way to connect an attestation to a product:

git subject (remote URL) → ProductRepository → Product → PolicyBinding → policy

No git subject → no product → no policy to check → fail closed. That's why the "blocked" case in the demo builds with -a docker only.

Did we write our own verifier?

No, and this is worth stressing. policyverify.RunSync is the platform's existing policy evaluator — it predates this feature by ~2.5 months, and the cryptography under it dates to 2024. Our entire contribution inside it is 38 lines: an option that swaps where the evidence comes from.

var collectionSource source.Sourcer = NewEntSource(...)      // default: our database
if cfg.collectionSource != nil { collectionSource = cfg.collectionSource }  // ours: JFrog

Same engine, same signature checks, same verdict row. Different input. That's why a PASSED here means exactly what a PASSED means anywhere else in the product.

How do we know verify really reads JFrog and not our own database?

We tested it. An attestation that existed in Archivista but had never been pushed to JFrog made verify error out. Ones present in JFrog passed. If it were reading our database, both would have passed.

Why is there a repo_key and why does it matter?

JFrog paths look like host/docker-local/image. That middle segment is the repo key. It's also a security boundary: the repository string comes from the attestation subject — i.e. from whoever produced the evidence. If we matched on hostname alone, an attestation naming a different namespace on the same host could make the platform use your stored credentials there. With a repo key set, we require the image to be under that namespace.

What's hostguard.go for?

Registry URLs are customer-supplied. Without a guard, an admin could register a "registry" pointing at localhost, an internal IP, or the cloud metadata endpoint, and turn the platform into a proxy into its own network (SSRF). We check the resolved IP at connection time (which also defeats DNS rebinding), and we explicitly turn off HTTP proxying — with a proxy set, the check would inspect the proxy's address while the proxy connected to the customer's host.

Which registries actually work?

JFrog is tested end to end. Docker Hub and ghcr are offered in the UI and use the same code path, but are not tested — don't claim them. ECR and Harbor were removed from the UI: ECR needs an AWS token exchange we don't implement, Harbor was never stood up. (Your diagram's panel 7 still lists ECR and Harbor as in-scope — fix that.)

What if the registry is down or the credential is wrong?

The push activity retries 3× with backoff, then records a failure. The attestation is already safe in the platform, so nothing is lost — a retry or a manual attach republishes it.


PART 5 — What's still open (say these first)

Volunteering these is worth more than surviving them.

1. Cross-replica publish race — open

Two copies of the platform can both read the registry manifest, each add their own attestation layer, and the second write silently erases the first. No error anywhere.

This isn't something we invented — it's built into the cosign tag layout. The OCI project documents it about this exact mechanism:

"The client requirement to manage the fallback tag is subject to race conditions since the existing content of the fallback tag must first be queried to add the new entry" — https://opencontainers.org/posts/blog/2024-03-13-image-and-distribution-1-1/

The cause: OCI registries have no compare-and-swap on a tag. You cannot say "write this only if nothing changed." So read-modify-write can't be made atomic. witness attach attestation has the same exposure.

What we shipped: publishes to one tag are serialized inside one process. Single-replica deployments — including everything in the demo — are fully covered.

The real fix: the OCI Referrers API. Each attestation becomes its own manifest pointing at the image, so every push is unique and nothing can be overwritten. JFrog supports this from Artifactory 7.90.1. https://jfrog.com/help/r/jfrog-artifactory-documentation/use-referrers-rest-api-to-discover-oci-references

Interim option: a Postgres advisory lock keyed on the tag (~40 lines), which closes it for all platform-originated writes.

2. We reversed a one-way counter

systemviewer_elimination_test.go counts places that use a god-mode viewer that bypasses tenant isolation. There's an active project to get it to zero, and the file says the number can only go down. We took it 29 → 32.

Our three uses are in the publish matcher, credential loader, and policy gate. The reason: the ingest path has no logged-in user attached, and the credentials aren't reachable over GraphQL. We compensate by adding an explicit tenant filter to every query.

The weak spot: the recommended alternative (scoped grants via internalgrant) is already used elsewhere in this same feature. So the honest line is "we didn't attempt it," not "we couldn't." Offer it as follow-up.

3. A widened privacy bypass

policyverify/sync.go now saves the verdict with a privacy override, because a user-triggered verify succeeded but was blocked when saving the result. The reasoning is sound, but it affects every caller of that function, not just ours. Narrowing it to our path is a small follow-up.

4. Credentials are plaintext at rest

Same as the existing compliance-integration connector — relies on Postgres encryption at rest. Consistent with the codebase, but state it rather than let it be discovered.

5. If CI is red

Check whether it's TestTriggerComplianceSync_RejectsConcurrentSyncs. That one fails on main too — a compliance test with a 30-minute time boundary. Not ours; verified against a clean main checkout.


Three sentences to land

  1. The attestation is in your registry, addressed by the digest of the image it describes, in a format your own tooling already reads.
  2. We verify by pulling it back out of the registry, not from our database — and we proved that by testing one that was missing from JFrog and watching verification fail.
  3. A registry can refuse to publish evidence that failed its policy, and it fails closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment