Skip to content

Trust boundaries

Three boundaries carry the security weight of the system. Everything else is plumbing.

Changes here need adversarial tests

A change touching facade authentication, ApprovalService, or the registration allowlist should ship with negative tests — rejected schemes, revoked tokens, quarantine unreachability, push attempts — not only happy-path coverage.

Container view

C4Container
    title Skills Gateway — container view

    Person(reviewer, "Reviewer / operator", "Registers marketplaces, approves snapshots, manages access tokens")
    Person(developer, "Developer / CI", "Installs skills with an unmodified git client")

    System_Ext(upstream, "Upstream git forge", "Hosts the marketplace repository")
    System_Ext(idp, "OIDC identity provider", "Authorization-code login for the web surface")

    Container_Boundary(gw, "Skills Gateway") {
        Container(spa, "Admin portal", "React, Vite", "Served from the same jar at / behind OIDC")
        Container(api, "Admin API", "Spring MVC, /api/**", "Registration, ingestion, approval, provenance, tokens, ledger read")
        Container(facade, "Git facade", "JGit GitServlet, /git/**", "Read-only smart-HTTP, PAT auth, receive-pack disabled")
        Container(publish, "Publish endpoint", "JGit GitServlet, /publish/**", "Push into a hosted marketplace's origin, PAT push scope")
        ContainerDb(db, "PostgreSQL", "Flyway, JdbcClient", "Marketplaces, snapshots, access tokens, append-only fetch_log")
        ContainerDb(store, "Git store", "Filesystem bare repos", "hosted/, quarantine/ and published/{name}.git")
    }

    Rel(reviewer, spa, "Uses", "HTTPS")
    Rel(spa, api, "Calls", "JSON over session cookie")
    Rel(reviewer, idp, "Authenticates with", "OIDC")
    Rel(api, idp, "Validates login", "authorization code")
    Rel(api, upstream, "Clones the default branch", "HTTPS via JGit")
    Rel(api, store, "Writes quarantine pins, publishes refs/heads/main")
    Rel(api, db, "Reads and writes")
    Rel(developer, facade, "git clone / fetch", "smart-HTTP + PAT")
    Rel(facade, store, "Reads the published repo only")
    Rel(facade, db, "Appends fetch entries")

1. Registration — an operator's URL becomes an outbound fetch

Registering a marketplace hands the gateway a URL it will later clone. Two gates apply before the row is written.

URL scheme allowlist. The URL is parsed and its scheme lower-cased and matched against skills-gateway.allowed-url-schemes (default http, https). Anything else — file:, ssh:, ext:, and any URL that fails to parse or carries no scheme at all — is rejected fail-closed with 400. The same check guards every operator-supplied outbound URL in the product.

Gateway-pinned ref. The ref field must be absent or exactly main. Which ref gets ingested is the gateway's decision, not the registrant's. Multi-ref publication is a designed future feature and will arrive as promotion per (upstream, ref) — not by relaxing this check.

Marketplace names are additionally constrained to ^[a-z0-9][a-z0-9_-]*$, which is also what makes them safe as path segments on the facade.

See Compatibility and allowlists for the full matrix.

Declared marketplaces enter through the same gate. The declarative estate block is a second caller of this boundary, never a second implementation: a declared marketplace faces the same name rules, reserved name and scheme allowlist as an API registration, has no ref key to declare, and a failing entry is reported rather than registered. Reconciliation is additive — the declaration can create and converge, never deregister.

2. The facade — an anonymous network peer becomes a reader

/git/** is served by its own Spring Security filter chain, ordered ahead of the web chain, and it is stateless:

  • HTTP Basic, with a provider manager whose only provider is the PAT provider. An OIDC browser session can never authenticate a git fetch, because no OIDC provider exists in that chain.
  • Only the password field is read; the username is ignored (token by convention). This is what makes the standard git credential helper work unmodified.
  • Tokens are sgw_ + Base64url of 32 random bytes. Only an unsalted SHA-256 hex digest is stored, and the cleartext is returned exactly once. Unsalted is deliberate — these are high-entropy random tokens, not user-chosen passwords.
  • A token may be scoped to named marketplaces (GW_0064) and may expire (GW_0065). Both are enforced where they matter: expiry at authentication (an expired token is refused exactly like a revoked one, by comparison, with no sweep), scope in the facade resolver — an out-of-scope request gets the same not-found a nonexistent marketplace gets, so a scoped token cannot probe what else the gateway governs.
  • Every fetch entry on the ledger names the token that authenticated it (GW_0067), not just the principal: a principal with several tokens is several distinct credentials — and says whether that credential was derived from a browser session (GW_0104) or deliberately provisioned, which is a different question from who held it.
  • Receive-pack is disabled by construction on this endpoint, so there is no write path here to reject at runtime. Publication into a gateway-hosted marketplace is a separate boundary — see below.
sequenceDiagram
    participant G as git client
    participant F as gitChain (Order 1)
    participant PAT as PatAuthenticationProvider
    participant S as Git storage
    participant L as fetch_log

    G->>F: GET /git/acme/info/refs?service=git-upload-pack
    F-->>G: 401 + WWW-Authenticate Basic
    G->>F: retry with Basic token:sgw_...
    F->>PAT: authenticate (password only)
    PAT->>PAT: sha256Hex(token) → find active token
    alt no active token
        PAT-->>G: 401 bad credentials
    else valid
        PAT-->>F: principal + ROLE_GIT
        F->>S: publishedIfServing("acme")
        alt never approved
            S-->>G: 404 repository not found
        else serving
            S-->>F: published repo (read-only)
            F->>L: record info-refs
            F-->>G: advertise refs/heads/main
            G->>F: POST /git/acme/git-upload-pack
            F->>L: record upload-pack per wanted object
            F-->>G: packfile
        end
    end

The facade chain is unconditional

The dev-insecure-auth escape hatch does not touch it. /git/** requires a valid PAT even in development mode.

3. Approval — held content becomes served content

ApprovalService is the only publisher. The invariant is structural rather than procedural: nothing else in the codebase writes to {data-dir}/published/, and the facade reads nothing else.

An approved or rejected snapshot cannot be decided again, so the published ref only ever advances through a deliberate, recorded decision.

It can also retreat, and only one thing makes it: an enforced re-vetting violation, which removes the published refs and moves the snapshot to revoked. That path never publishes — it only unpublishes — so ApprovalService remains the sole publisher, and a revoked snapshot returns to being served only by going back through it.

Gates precede every publication, inside that single publisher and before any state transition: the fail-closed vetting gate (evidence about the content, waivable finding by finding), then the fail-closed policy gate (standing organizational deny rules, not waivable — the exception path is editing the rule, audited), then the cooling-off window, and last the four-eyes rule. A refusal by any of them leaves the snapshot held and publishes nothing.

The last one guards a different property from the others, and it is the property the whole boundary rests on. Vetting and policy ask is this content acceptable; four-eyes asks is this decision an independent one — whether the reviewer is the marketplace's registrant, the snapshot's ingestion actor, or the author of a waiver the approval leans on. Without it, one identity could carry content from an upstream URL all the way to the facade unaccompanied, and every recorded decision on the way would still look correct.

It is the one gate whose strictness is a deployment decision rather than a constant. Under the default warn the conflict is recorded on the ledger and the approval proceeds — a deployment with a single administrator has no second pair of eyes to offer, and refusing there would only make the gateway unapprovable; under enforce the approval is refused. What has no off switch is the detection: a self-approval is on the ledger either way, which is what keeps warn a measurement rather than a blind spot. The automated sync triggers are not identities and never conflict.

None of these gates can ever open: none can approve. Policy rules in particular cannot auto-approve, because that would delegate the human decision this boundary exists to guarantee to an expression — a trust-model change, decided deliberately or not at all (ADR 0006 in Architecture decisions).

The web surface

Everything that is not /git/** is the web chain: OIDC authorization-code login only, with the application acting as its own BFF. The browser never holds a token; the session cookie is its only credential. /actuator/health is the sole unauthenticated path.

Requests to /api/** that lack a session get a clean 401 rather than a 302 to the identity provider, so an expired session surfaces in the SPA as an error instead of an HTML login page rendered into a fetch().

dev-insecure-auth

skills-gateway.dev-insecure-auth=true makes the entire web surface unauthenticated and injects a synthetic principal dev. It exists for local development, logs a loud warning at startup, and must never be set in a deployed environment. See Local development.

Being off by default is the control; the second one is a startup guard. A gateway that has an identity provider configured and this flag on refuses to start, because the login it configured is the login the flag switches off. See Configuration.

It opens the browser surface only. A request carrying an Authorization: Bearer header is authenticated strictly even in this mode, the way the git facade is: a mode in which every bearer value authenticated would be a very quiet way to lose the control plane in a copied configuration.

The machine API — a credential in a pipeline becomes a control-plane caller

/api/** has a second entrance, taken by a request that carries an Authorization: Bearer header. It is a sibling of the git facade's chain rather than a mode on the web chain: stateless, no session created, no cookie honoured, and a request presenting both a bearer credential and a cookie is refused rather than resolved to either.

Authentication is a machine API credential — an access token whose API scope list is non-empty. That non-emptiness is a precondition of authentication, not a later authorization rule, so no controller can be the single point of failure. A perfectly valid personal access token — including the every-marketplace form, the most permissive fetch grant the system has — does not authenticate here at all.

The guarantee is symmetric. A credential holding API scopes reaches no marketplace through the facade and no marketplace through publication, including the marketplaces its empty fetch scope list would otherwise grant: the fetch default is conditional on the credential's shape.

Authorization is an allowlist over per-concern named scopes, enforced in the filter chain and therefore independent of skills-gateway.roles.enabled. Reach is the intersection of the allowlist, the credential's scopes and its principal's roles — never their union. Every act of human judgement, every operation that retracts or republishes content, every role grant and the whole of /api/tokens/** sits outside the allowlist, and no combination of scopes and no role reaches them. An endpoint added later is unreachable until somebody names it; a build-time check refuses an unclassified one.

Attribution. Every ledger entry now carries an explicit actor type — human, machine or system — beside the identity it names, denormalised so a row written years ago still says what it meant after the credential it names has been revoked and its row deleted. That replaces an implicit vocabulary in which config-reconciler, scheduler and system were magic strings in the identity column. A machine entry also carries the credential's id, so a leak trace has per-credential resolution.

See Access tokens.

4. The inbound webhook — a forge's push event becomes a fetch

POST /hooks/{marketplace} is the one endpoint reachable without an OIDC session or a PAT, so its authentication is cryptographic and its authority is deliberately nil. Authentication: an HMAC-SHA256 signature of the exact raw request body (GitHub-compatible X-Hub-Signature-256), verified in constant time against a per-marketplace secret the gateway generated and returned exactly once. Authority: the payload is never read — a valid signature only triggers ingestion of the registered upstream URL's default branch, which lands held in quarantine exactly as the polling sweep would have produced.

The worst a forged-but-signed request can cause is therefore a redundant fetch of content the gateway already governs; nothing on this path can name a URL, a ref, or a commit, and nothing on it can approve or publish. Body size is bounded before the HMAC is computed, and requests for marketplaces not in webhook mode are refused without revealing why.

5. Publication — a publisher's push becomes quarantined content

A marketplace the gateway hosts itself has no upstream: its content arrives by git push to /publish/{name}. That is the only write path the gateway has, and four things keep it from being a way around the rest of this page.

It is somewhere else. A separate servlet, resolving a separate repository, behind a separate filter chain. /git/** keeps its null receive-pack factory, so no push can reach a published repository — not by misconfiguration, because there is no shared object to misconfigure (ADR 0007).

It writes to neither quarantine nor published. A push lands in the marketplace's origin repository. Ingestion then fetches out of it into quarantine exactly as it fetches from an upstream URL, so quarantine keeps the property that only the ingestion service writes it — and the refs/snapshots/<sha> namespace that vetting and approval address content by stays out of any external credential's reach.

The credential is one nobody holds. Push authority is a token scope separate from fetch scopes, and where an absent fetch scope means every marketplace (the compatibility rule for tokens predating scoping), an absent push scope means none. No token issued before this existed can publish, and no token can be granted publication to everything by omission. A push for a marketplace outside the scope answers exactly as one for a marketplace that does not exist.

A publisher may move one lineage, forward. Only refs/heads/main may be updated; no ref may be deleted; history may not be rewritten unless the marketplace was registered saying it may — and when it may, both tips land on the ledger, so "the lineage under that approved snapshot was rewritten" stays answerable.

What a push does not do is shorten the pipeline. The commit is quarantined, manifest-checked, vetted and held like anything fetched, and is served only after a human approves it. First-party content removes a redundant system, not the review.

6. Roles — what an authenticated session may do

Authentication says who a session is; the role model says what it may do. Three roles, enforced at the REST API by an explicit authorization call at the top of every privileged endpoint:

Role Scope Authority
admin global Every operation, including managing grants.
approver one marketplace Ingest, approve, reject, re-vet, waive — for that marketplace only. The owning marketplace is resolved from the addressed snapshot or waiver on the server side, so a bare id cannot reach another marketplace's content.
auditor global Read the ledger, its export, the operational listings. No mutations.

Enforcement is deny-by-default once enabled: a session with no role keeps the browsing surface and its own tokens, and is refused everything else. It is off by default (skills-gateway.roles.enabled=false) so an upgrade never locks a deployment out of its own gateway; principals in skills-gateway.roles.admins are admins by configuration and cannot be revoked through the API — the escape hatch that makes enabling safe. Every grant and revocation is on the append-only ledger.

A role can come from three places, and the union is what a session holds: the skills-gateway.roles.admins list, a grant row, or a claim of the identity provider's token mapped by skills-gateway.roles.mappings. Claim mapping moves part of this boundary into the directory — deliberately, because that is where membership is already governed — under three rules that keep it from widening it. Values are matched exactly, so a mapping cannot grant by resemblance. A malformed mapping refuses startup, so a typo cannot quietly grant nothing. And claims are read only from a session established through the identity provider: a PAT, the dev-insecure-auth principal and the anonymous webhook request carry no claims and derive no role, whatever authorities they hold.

The identity token's own integrity is part of this: the gateway configures its provider endpoints explicitly rather than by discovery, so it compares the token's issuer only when skills-gateway.oidc.issuer names one. Where a single authorization endpoint serves many tenants, that comparison is the tenant boundary — every tenant's tokens verify against the same keys.

This boundary is the web surface's only. The facade's authorization is token scopes — a different credential for a different surface — and roles never apply to PATs. See Delegated administration for the workflow, Identity providers for claim mapping, and Roles for the grants API.

The storage itself

Publication is a reference transition on the published repository, and the boundary is therefore wherever those references physically live.

On the filesystem backend that is the mounted volume: anyone who can write to it can move refs/heads/main without ApprovalService ever running. That has always been true, and it is why the volume inherits the encryption and access expectations of the content it holds.

On the object-store backend it is the bucket. The served reference map is one small object; anyone who can write that object can put content on the wire without approval, and can do it from outside the gateway entirely. The mitigation is a narrow bucket policy — object read, write and delete under the gateway's own prefix, no bucket administration — and treating the bucket as the same kind of asset the volume was.

The gateway cannot enforce this. It has no way to distinguish its own write from anyone else's, and no way to detect one that already happened. What it does do is refuse to start against a store whose conditional writes are not faithful, so that its own concurrent writers cannot lose a transition. That is a different guarantee, and it is not a substitute for the policy.

What is not a boundary yet

A second recorded approval — a queue in which two identities each decide, rather than one deciding while the gateway checks who they are — is not implemented. The four-eyes rule refuses a conflicted approval; it does not require two approvals of an unconflicted one.

Per-team catalog scoping (which identities see which virtual marketplaces) and a portal UI for managing grants are future capabilities; today the grants API and the claim mappings are the management surface. Where the role model is not enabled, treat access to the portal as the reviewer privilege and grant it through the identity provider.