Configuration¶
Every setting the gateway reads, with its default and what consumes it.
Summary¶
| Block | Purpose | Required in production |
|---|---|---|
skills-gateway.* |
Storage location, the URL-scheme allowlist, and the development auth escape hatch. | No — all defaulted. |
skills-gateway.ingestion.* |
Whether a manifest may declare plugin sources outside the marketplace repository, and within what bounds. Admits nothing by default. The credentials private upstreams are read with. None by default. | No — all defaulted. |
skills-gateway.webhooks.* |
Outbound lifecycle-webhook dispatch: poll interval, retry budget and backoff. | No — all defaulted. |
skills-gateway.audit-export.* |
Ledger export: the commit-settling lag, batch and page sizes. | No — all defaulted. |
skills-gateway.retention.* |
Snapshot retention policies, the schedules that apply them, and the sweep of abandoned publication staging refs. Off by default. | No — all defaulted. |
skills-gateway.approval.* |
Separation of duties on approval: whether a reviewer may publish content they themselves supplied (records by default; enforcement is opt-in), and the plugin-name collision rule (on by default). | No — all defaulted. |
skills-gateway.sync.* |
Upstream sync: the polling sweep's schedule and batch, and the inbound webhook body bound. | No — all defaulted. |
skills-gateway.catalog.* |
The global virtual catalog and its reserved name. | No — all defaulted. |
skills-gateway.tokens.* |
Access-token policy: the maximum lifetime creation accepts. | No — defaulted (unlimited). |
skills-gateway.roles.* |
The administrators and claim mappings the gateway derives roles from. Enforcement is always on. | Yes — a deployment that names no administrator refuses to start. |
skills-gateway.estate.* |
The declared estate: marketplaces, role grants, webhook subscribers, audit sinks, policy rules — reconciled at startup and on demand. Empty by default. | No — empty by default. |
spring.datasource.* |
PostgreSQL connection. Supplied entirely by environment. | Yes |
spring.security.oauth2.client.* |
OIDC login for the web surface. | Yes |
server.servlet.session.cookie.same-site |
Whether the browser sends the session cookie on a cross-site request. | No — set to lax in application.yaml. |
server.forward-headers-strategy |
Whether the scheme and host a TLS-terminating proxy reports are believed. Off by default. | Yes, behind a proxy — which is every real deployment. |
management.endpoints.* |
Which actuator endpoints are exposed. | No |
scalar.* |
The bundled API reference UI. | No |
There are no validation annotations anywhere in the properties record. Every field is nullable and its default is applied in the constructor, which means omitting a whole block and omitting individual keys behave identically.
skills-gateway¶
The application's own namespace. Only data-dir appears in
application.yaml; the rest are Java-side defaults.
skills-gateway:
# Root of local storage. On the filesystem backend three subdirectories are
# created beneath it:
# {data-dir}/quarantine/{marketplace}.git — never served
# {data-dir}/published/{marketplace}.git — what the facade reads
# {data-dir}/hosted/{marketplace}.git — a hosted marketplace's origin
# On the object-store backend the repositories are in the bucket and this
# holds only the local pack cache, which is never authoritative.
# The container image sets SKILLSGATEWAY_DATADIR=/data instead.
data-dir: data
# URL-scheme allowlist for every operator-supplied outbound URL.
# Compared lower-cased. A URL that fails to parse, or carries no scheme,
# is rejected — the check fails closed. Rejection is HTTP 400.
# An empty list is not configurable: it falls back to the default.
allowed-url-schemes:
- http
- https
# DEVELOPMENT ONLY. Makes the entire web surface unauthenticated and
# injects a synthetic principal "dev". Logs a warning at startup.
# Does not affect the git facade, which always requires a PAT.
dev-insecure-auth: false
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.data-dir |
path | data |
Relative to the working directory. /data in the container image. |
skills-gateway.allowed-url-schemes |
list of string | [http, https] |
Governs registration and an admitted external plugin source's clone URL. See Compatibility and allowlists. |
skills-gateway.dev-insecure-auth |
boolean | false |
Must stay false outside a development loop. |
dev-insecure-auth in a deployed environment
It permits all of /api/**, /actuator/** and /docs without
authentication, and attributes every audit entry to dev. There is no
partial mode.
The gateway refuses to start where the flag cannot belong
The escape hatch exists for a development loop that has no identity
provider to log in to. So a gateway with dev-insecure-auth: true and an
identity provider configured refuses to start, naming what it decided on
and both ways out. It counts a provider as configured when any of these is
true:
- an OIDC client registration carries a client id other than the shipped
change-meplaceholder; - a provider endpoint (
authorization-uri,token-uri,jwk-set-uri,issuer-uri) names a host other than the shippedidp.invalidplaceholder; skills-gateway.oidc.issueris pinned.
There is no property that switches the guard off — the way out is to stop
setting dev-insecure-auth. Note what the guard cannot see: a deployment
with no identity provider at all is indistinguishable from a laptop, so it
is not a substitute for keeping the flag out of your deployed configuration.
Git storage¶
Which storage holds the repositories, and how to reach it. The backend is
named, never inferred: an absent block is the filesystem, an unrecognised
name fails startup, and an object-store selection missing what it needs fails
startup naming the missing setting. There is no fallback in either direction —
a gateway serving from local disk while the operator believes it is serving from
a bucket reports healthy and is wrong from the outside.
For choosing between the two, and for moving an estate from one to the other, see Choosing and migrating the storage backend.
skills-gateway:
storage:
# filesystem (default) | object-store
backend: filesystem
object-store:
bucket: skills-gateway
region: eu-north-1
# Empty for the regional AWS endpoint; set it for an S3-compatible store
# or for an S3 VPC endpoint.
endpoint: ""
# Key prefix, so one bucket can hold more than one gateway.
prefix: ""
credentials:
# default | web-identity | static
mode: web-identity
# web-identity: both fall back to AWS_ROLE_ARN and
# AWS_WEB_IDENTITY_TOKEN_FILE, which is what an annotated service
# account projects into the pod.
role-arn: ""
token-file: ""
# static only, for stores with no role mechanism.
access-key-id: ""
secret-access-key: ""
cache:
# Local pack cache; nothing in it is authoritative and deleting it at
# any moment is safe. Defaults to {data-dir}/object-store-cache.
dir: ""
max-bytes: 2147483648
block-size-bytes: 65536
block-cache-bytes: 268435456
# How long a pack nothing references is kept before its objects are
# deleted, so a fetch already streaming from it is not cut off.
pack-grace: 1h
# Below the store's own idle timeout: a connection the store has already
# closed reads, on the first request after a quiet period, as a storage
# fault rather than as the pooling artefact it is.
connection-max-idle-time: 20s
connection-time-to-live: 1m
# The offline one-shot copy between backends. Off unless asked for.
migration:
enabled: false
# The destination; the source is whatever `backend` above names.
to: object-store
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.storage.backend |
filesystem | object-store |
filesystem |
An unrecognised value fails startup listing the accepted ones. |
skills-gateway.storage.object-store.bucket |
string | — | Required on object-store. |
skills-gateway.storage.object-store.region |
string | — | Required on object-store; an unsigned-for region fails mid-approval rather than at startup. |
skills-gateway.storage.object-store.endpoint |
URL | SDK regional | An S3-compatible store, or an S3 VPC endpoint. |
skills-gateway.storage.object-store.prefix |
string | "" |
Key prefix inside the bucket. |
skills-gateway.storage.object-store.credentials.mode |
default | web-identity | static |
default |
See below. |
skills-gateway.storage.object-store.credentials.role-arn |
string | AWS_ROLE_ARN |
web-identity only. |
skills-gateway.storage.object-store.credentials.token-file |
path | AWS_WEB_IDENTITY_TOKEN_FILE |
web-identity only. |
skills-gateway.storage.object-store.credentials.access-key-id |
string | — | static only. |
skills-gateway.storage.object-store.credentials.secret-access-key |
string | — | static only. Never logged, audited or echoed by any API. |
skills-gateway.storage.object-store.cache.dir |
path | {data-dir}/object-store-cache |
Safe to delete at any time. |
skills-gateway.storage.object-store.cache.max-bytes |
bytes | 2 GiB |
Bound on the on-disk pack cache. |
skills-gateway.storage.object-store.cache.block-size-bytes |
bytes | 64 KiB |
JGit DfsBlockCache block size. |
skills-gateway.storage.object-store.cache.block-cache-bytes |
bytes | 256 MiB |
JGit DfsBlockCache size. |
skills-gateway.storage.object-store.cache.pack-grace |
duration | 1h |
Grace before an unreferenced pack's objects are deleted. |
skills-gateway.storage.object-store.connection-max-idle-time |
duration | 20s |
Keep below the store's idle timeout. |
skills-gateway.storage.object-store.connection-time-to-live |
duration | 1m |
Upper bound on a pooled connection's life. |
skills-gateway.storage.migration.enabled |
boolean | false |
Makes this start a migration instead of a service. |
skills-gateway.storage.migration.to |
filesystem | object-store |
— | Required when enabled; must differ from backend. |
…cache.ref-freshness was removed, and setting it refuses startup
It set how long a replica could keep serving a reference map it had already
read — in effect, how long a revoked snapshot could still be advertised by
a replica that did not perform the revocation. That is a property of the
revocation path rather than a tuning dial, so it is no longer settable:
every reference advertisement is preceded by a conditional GET of the
repository manifest, and the bound is the next advertisement.
It is refused rather than ignored for the same reason
skills-gateway.roles.enabled is. A deployment that set it was asking for
a longer bound; ignoring it would shorten the bound silently, which is the
right behaviour arrived at by the wrong route — the operator would still
believe the value they wrote was in force.
Remove the property. What you get without it is what the default already
gave you: it defaulted to zero, and the documented 10s default recorded
here until this change never existed in the code. The refusal is a
migration aid and is scheduled for removal at the next major version.
Credential modes¶
| Mode | Where credentials come from | Use it for |
|---|---|---|
default |
The AWS SDK's own provider chain | A host where the chain already resolves, and where an instance metadata service exists |
web-identity |
A projected service-account token exchanged for a role | Workload identity (IRSA and its equivalents). The gateway holds no secret at all, and it is the only mode that works where there is no instance metadata service |
static |
An access key pair | Stores with no role mechanism |
Write access to the bucket is publication
On the object-store backend the served reference map is an object in the
bucket. Anyone who can write it can put content on the wire without going
through approval. Give the gateway a narrow policy — object read, write and
delete under its own prefix, and no bucket administration — and treat the
bucket as part of the trust boundary the volume already was. See
Trust boundaries.
Conditional writes are the portability boundary
The backend serializes every reference transition with a conditional write
(If-Match / If-None-Match) on one small object. A store that does not
implement those cannot be supported by weakening the model, so the gateway
probes the configured bucket at startup and refuses to run where the probe
fails. The stores this has actually been exercised against are listed in
the storage guide.
Webhooks¶
Tuning for the outbound dispatcher that delivers marketplace lifecycle events. None
of it appears in application.yaml; every value below is a Java-side default,
and omitting the whole block is identical to omitting each key.
skills-gateway:
webhooks:
# Stops the polling dispatcher only. Events are still enqueued as delivery
# rows and the admin API keeps working, so turning this back on drains
# whatever accumulated instead of losing it.
enabled: true
# How often the dispatcher looks for due deliveries. Also the floor on
# end-to-end latency for the first attempt.
poll-interval: 5s
# Attempt n schedules the next attempt at base-backoff * 2^(n-1),
# never later than max-backoff.
base-backoff: 10s
max-backoff: 1h
# Total attempts per delivery, first one included. Reaching it marks the
# delivery 'failed'; it is never retried again.
max-attempts: 5
# Connect and read timeout for the outbound POST. The claim lease is
# derived from it, so a slow receiver cannot strand a delivery.
timeout: 10s
# Deliveries claimed per poll pass.
batch-size: 50
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.webhooks.enabled |
boolean | true |
false pauses delivery; it does not stop emission. |
skills-gateway.webhooks.poll-interval |
duration | 5s |
Fixed delay between dispatch passes. |
skills-gateway.webhooks.base-backoff |
duration | 10s |
First retry delay; doubles per attempt. |
skills-gateway.webhooks.max-backoff |
duration | 1h |
Ceiling on the doubling. |
skills-gateway.webhooks.max-attempts |
integer | 5 |
Attempt budget per delivery. Zero or negative falls back to the default. |
skills-gateway.webhooks.timeout |
duration | 10s |
Connect and read timeout per attempt. |
skills-gateway.webhooks.batch-size |
integer | 50 |
Deliveries claimed per pass. Zero or negative falls back to the default. |
Raising the retry budget raises the retry window
The delays compound: with the defaults a delivery is abandoned about two
minutes after the event. max-attempts: 12 with the same base reaches the
one-hour cap and keeps a dead receiver in the queue for hours. Receivers
must de-duplicate on the delivery id regardless — see
Receiving lifecycle webhooks.
Read-only forge mirror¶
An optional copy of approved content on an external code host, so people can browse and search it with that host's own tools. Off by default: a deployment that sets none of this pushes nothing anywhere.
The mirror is visibility only. It is never a serving surface, never an enforcement path, and nothing about approval, revocation or what the facade serves depends on it. See The read-only forge mirror.
skills-gateway:
mirror:
# Nothing below is read until this is true.
enabled: false
# The single marketplace this mirrors. Required when enabled.
marketplace: corp-marketplace
# The mirror's clone URL. Its scheme must be on
# skills-gateway.allowed-url-schemes, the same allowlist that governs
# marketplace registration, and it may not embed a credential.
url: https://forge.example.com/mirrors/corp-marketplace.git
# The push credential. Configuration or environment; never committed.
username: ${SGW_MIRROR_USERNAME}
token: ${SGW_MIRROR_TOKEN}
# Per-operation transport timeout.
timeout: 30s
# Attempts per mirror update, first one included. Exhausting them leaves the
# mirror drifted until the next reconciliation; it never affects the decision
# that triggered the update.
max-attempts: 3
retry-delay: 5s
# The recurring reconciliation: on with the mirror, and the bound on how long
# a reference the facade no longer serves can stay on the mirror.
sweep-enabled: true
sweep-interval: 15m
# Short on purpose. A restart is when a queued update is lost, so the first
# reconciliation after startup is the one that closes that gap.
sweep-initial-delay: 1m
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.mirror.enabled |
boolean | false |
Nothing else here is read while this is false. |
skills-gateway.mirror.marketplace |
string | — | Required when enabled. Exactly one marketplace is mirrored. |
skills-gateway.mirror.url |
URL | — | Required when enabled. Scheme must be on skills-gateway.allowed-url-schemes. |
skills-gateway.mirror.username |
string | — | Omit for a target that needs no credential. |
skills-gateway.mirror.token |
string | — | Never logged, audited or echoed by any API. |
skills-gateway.mirror.timeout |
duration | 30s |
Per transport operation. |
skills-gateway.mirror.max-attempts |
integer | 3 |
Attempt budget per mirror update. |
skills-gateway.mirror.retry-delay |
duration | 5s |
Delay between those attempts. |
skills-gateway.mirror.sweep-enabled |
boolean | true |
The recurring reconciliation. Irrelevant while the mirror is disabled; turning it off with the mirror on means drift is corrected only by the next approval or revocation. |
skills-gateway.mirror.sweep-interval |
duration | 15m |
How often it runs, and therefore the bound on how long the mirror may hold a reference the facade no longer serves. |
skills-gateway.mirror.sweep-initial-delay |
duration | 1m |
How long after startup the first one runs. |
An enabled mirror with unusable configuration refuses to start
A missing marketplace or URL, a scheme outside the allowlist, or a URL embedding a credential fails startup. Nothing is contacted to decide that — only the configuration is read — so a mirror that is merely down never affects startup or anything else.
Never install from the mirror
Agent installs and CI must keep pointing at the facade. A fetch from the mirror is not on the audit ledger, is not covered by the gateway's access tokens, and a revoked snapshot stops being served by the facade whether or not the mirror has caught up.
sweep-interval is a security setting
It is how long a mirror may keep showing a snapshot the gateway has revoked
when the revocation's own push failed. Lengthening it lengthens that window;
turning the sweep off removes the bound entirely and leaves the mirror
correct only as often as something is approved or revoked. Watch
skills_gateway.mirror.stale_refs either way — see
Observability.
Audit export¶
Tuning for the ledger export — the NDJSON pull endpoint and the scheduled
exporter that feeds push sinks. Java-side defaults again; nothing appears in
application.yaml.
skills-gateway:
audit-export:
# Stops the exporter poller only. The pull endpoint keeps serving and sinks
# keep their cursors, so turning this back on resumes where it left off.
enabled: true
# How often each enabled sink is offered the next batch.
poll-interval: 30s
# Commit-settling window. The ledger id is a BIGSERIAL assigned before
# commit, so a lower id can become visible after a higher one; both export
# paths withhold entries younger than this so a cursor cannot step over an
# append that was still in flight. The cost is staleness, not correctness.
lag: 5s
# Ledger entries per pushed batch, unless the sink overrides it.
batch-size: 500
# GET /api/v1/audit/export page size when ?limit= is omitted, and the ceiling
# it is clamped to when it is given.
default-page-size: 1000
max-page-size: 10000
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.audit-export.enabled |
boolean | true |
false pauses push export; the pull endpoint is unaffected. |
skills-gateway.audit-export.poll-interval |
duration | 30s |
Fixed delay between export passes. |
skills-gateway.audit-export.lag |
duration | 5s |
Entries younger than this are withheld from both paths. |
skills-gateway.audit-export.batch-size |
integer | 500 |
Per-sink default; a sink may set its own, clamped to max-page-size. Zero or negative falls back to the default. |
skills-gateway.audit-export.default-page-size |
integer | 1000 |
Applied when ?limit= is absent. Zero or negative falls back to the default. |
skills-gateway.audit-export.max-page-size |
integer | 10000 |
Hard ceiling on ?limit= and on a sink's batch size. Zero or negative falls back to the default. |
lag: 0 reintroduces the skip window
Setting the lag to zero lets a cursor advance past an entry that had an id but was not yet committed — that entry is then never exported, and the gap is silent. Shorten it only with evidence about your commit latency; a compliance feed with a hole in it is worse than one that is seconds behind.
Sinks, cursors and replay are described in Exporting the audit ledger.
Ingestion — upstream credentials¶
The credentials that private marketplace upstreams are read with. The list is empty by default, and an empty
list reads every upstream anonymously. Each entry is one of two kinds: a static
username and token, or a github-app that mints a short-lived token per
fetch. The task-shaped guide, with examples
for local development, Helm and ECS, is
Reading a private upstream.
skills-gateway:
ingestion:
upstream-credentials:
# The longest matching prefix wins. Prefixes match whole path segments.
- url-prefix: https://github.com/acme/
username: skills-gateway
# Always an environment reference, never the token itself.
token: ${SGW_UPSTREAM_ACME_TOKEN}
# A GitHub App instead of a token: no username or token on this entry.
- url-prefix: https://github.com/widgets/
github-app:
app-id: "123456"
private-key: ${SGW_UPSTREAM_APP_KEY}
installation-id: "7890" # optional
api-url: https://api.github.com # optional; GitHub Enterprise Server sets its own
| Key | Type | Default | Notes |
|---|---|---|---|
skills-gateway.ingestion.upstream-credentials |
list | [] |
One entry per URL prefix. Used by the registration check and by every ingestion and sync fetch. |
….upstream-credentials[n].url-prefix |
URL | — | https, or http to a loopback host only. No userinfo, query, fragment, dot segments or encoded separators. Scheme and host are compared without case, the port after defaults, and the path by whole segments; a trailing / makes no difference. |
….upstream-credentials[n].username |
string | — | Sent as the HTTP Basic user. |
….upstream-credentials[n].token |
string | — | Sent as the HTTP Basic password. Never logged, stored, audited or echoed by any API. |
….upstream-credentials[n].github-app.app-id |
string | — | The App's numeric id. Required in a github-app block. |
….upstream-credentials[n].github-app.private-key |
string | — | The App's RSA private key as PEM: PKCS#1 (BEGIN RSA PRIVATE KEY, what GitHub issues) or PKCS#8, unencrypted. A \n written as two characters counts as a line break. Never logged, stored, audited or echoed. |
….upstream-credentials[n].github-app.installation-id |
string | none | The installation to mint from. When absent, the gateway asks the API which installation covers each repository. |
….upstream-credentials[n].github-app.api-url |
URL | https://api.github.com |
The REST API tokens are minted at. https, or http to a loopback host only; no userinfo, query or fragment. Never taken from a marketplace URL. |
- Validated at startup. The gateway refuses to start on an entry with a
blank field, an unresolved
${…}reference, a prefix that breaks the rules above, or a prefix that another entry also declares. It also refuses an entry with both a token and agithub-appor with neither, a non-numericapp-idorinstallation-id, a key it cannot read as RSA, and anapi-urlthat breaks the rules above. The message names the entry's position and prefix, never its token or key. - GitHub App tokens. Per fetch the gateway signs an assertion with the key
(valid for under ten minutes) and exchanges it for an installation token
scoped to the one repository, with
contents: read. It reuses the token until five minutes before it expires, and after the upstream refuses a reused token it mints once more. The marketplace URL must behttps://<host>/<owner>/<repository>[.git]. - Sent per request. The credential that the marketplace URL selects rides only on requests under its own prefix, so a redirect elsewhere carries nothing. The forge metadata lookup and external plugin sources are always anonymous.
- Rotation is a restart. The list is read once, at startup. A GitHub App's installation tokens renew by themselves; its key is changed by a restart.
Ingestion — external plugin sources¶
Whether a marketplace manifest may declare plugins that live outside the
marketplace repository, and — when it may — what resolving them is allowed to
reach and to cost (GW_INGEST_0020, GW_INGEST_0023 – GW_INGEST_0026, GW_INGEST_0027). Java-side defaults; nothing
appears in application.yaml, and an absent block is the behaviour every
existing deployment already has.
skills-gateway:
ingestion:
external-sources:
# false is GW_INGEST_0003's local-only rejection: a manifest declaring a github,
# git, git-subdir, npm or archive source is refused, snapshot rejected.
# It is also the only setting under which the gateway makes no outbound
# request driven by manifest content.
enabled: false
# The types an enabled gateway will consider. Only types something can
# resolve belong here — the allowlist never advertises a form nothing
# implements, and today only github resolves.
allowed-types: [github]
# Exact hosts the derived clone URL may name; empty means any host. Never
# a suffix or pattern match: github.com does not admit evil-github.com.
allowed-hosts: []
# How many external sources one manifest may declare.
max-sources: 20
# Where an owner/repo shorthand is resolved, for GitHub Enterprise Server.
github-base-url: https://github.com
# Whether a source may resolve to a loopback, RFC1918, carrier-grade-NAT
# or unique-local address. It never permits a link-local address.
allow-private-networks: false
budgets:
max-received-bytes: 50MB # per source, on the wire
max-inflated-bytes: 200MB # per source, as objects
max-closure-bytes: 500MB # every source of one manifest, as objects
max-inflation-ratio: 100 # uncompressed bytes per received byte
max-objects: 20000 # per source
max-blob-bytes: 10MB # largest single file
max-tree-depth: 32
max-redirects: 3
deadline: 5m # whole resolution, wall clock
| Key | Type | Default | Notes |
|---|---|---|---|
skills-gateway.ingestion.external-sources.enabled |
boolean | false |
false is unchanged GW_INGEST_0003 behaviour, and no manifest-driven outbound request is made. |
skills-gateway.ingestion.external-sources.allowed-types |
list | [github] |
npm and archive are refused whatever this says; git and git-subdir are not resolved yet. |
skills-gateway.ingestion.external-sources.allowed-hosts |
list | [] (any) |
Exact-host matching on the derived clone URL. |
skills-gateway.ingestion.external-sources.max-sources |
integer | 20 |
Counts external sources, not plugins. |
skills-gateway.ingestion.external-sources.github-base-url |
string | https://github.com |
Trailing slashes are trimmed. The derived URL still faces the scheme and host allowlists. |
skills-gateway.ingestion.external-sources.allow-private-networks |
boolean | false |
Loopback, RFC1918, 100.64.0.0/10, fc00::/7. Never link-local. |
The nine resolution budgets carry a second number: the furthest this gateway will honour a raise.
| Key | Type | Default | Ceiling | Notes |
|---|---|---|---|---|
…external-sources.budgets.max-received-bytes |
size | 50MB |
500MB |
Enforced on the response stream, so an endless transfer is cut off rather than measured. |
…external-sources.budgets.max-inflated-bytes |
size | 200MB |
2GB |
One source's content as objects. |
…external-sources.budgets.max-closure-bytes |
size | 500MB |
5GB |
Accumulated across every source one manifest declares. |
…external-sources.budgets.max-inflation-ratio |
integer | 100 |
1000 |
Judged only once a source's content passes a quarter of max-inflated-bytes; below that the absolute bound already caps it. |
…external-sources.budgets.max-objects |
integer | 20000 |
200000 |
Blobs and directories one source may contribute. |
…external-sources.budgets.max-blob-bytes |
size | 10MB |
100MB |
Largest single file. |
…external-sources.budgets.max-tree-depth |
integer | 32 |
256 |
Bounds every later walk of the content, not only the fetch. |
…external-sources.budgets.max-redirects |
integer | 3 |
10 |
Hops one request may take. |
…external-sources.budgets.deadline |
duration | 5m |
30m |
Wall clock for resolving a whole manifest. |
A budget can be lowered without limit and raised only so far
The Ceiling column is the furthest this gateway will honour. Set a budget above it and the gateway uses the ceiling instead, logs a warning naming the key, the value you configured and the value in force, and starts normally (GW_INGEST_0031 — A configured resolution bound is honoured only as far as the gateway can defend it).
Lowering is never clamped. These bounds exist so that no manifest the gateway will look at can exhaust the gateway, and a bound settable to any value is not that — it is a control an operator can switch off by typing a large number, after which the gateway behaves as an unbounded resolver while its configuration still claims a limit. Every ceiling is far above its default, so a marketplace that legitimately outgrows one still has somewhere to go.
Every budgets.* key except max-redirects is its own sub-requirement of
GW_INGEST_0026 — Resource-bounded source resolution, in the order the table lists
them: GW_INGEST_0026.1 through GW_INGEST_0026.8. Raising one of them is therefore a
decision about one stated bound, and a traceability report names the bound that
regressed rather than the whole budget. max-redirects belongs to GW_INGEST_0025 —
Address, redirect and transport policy for source resolution, which is where
redirect policy lives.
A source's derived clone URL must also satisfy skills-gateway.allowed-url-schemes
— the same allowlist that governs registration, so there is one scheme policy for
every URL the gateway will ever dereference. A github shorthand is expanded
against github-base-url before the scheme and host checks, and a shorthand that
is not exactly owner/repo — or that contains a . or .. segment — is refused
rather than expanded.
Every breach of a budget, of the address policy or of the redirect policy is a rejected snapshot whose violation names what was exceeded and which plugin caused it, so an operator is led to the number to change rather than to a log.
What an enabled gateway does with a source¶
An admitted github source is fetched into the marketplace's quarantine
repository, grafted into the snapshot under _plugins/<plugin name>/, and the
served .claude-plugin/marketplace.json is rewritten so that plugin's source
is ./_plugins/<plugin name>. The snapshot is that synthesised commit, and its
parent is the commit ingested from upstream — see
Snapshots and the ledger.
Refused, each with its own violation and no partial result: a marketplace whose
repository already has a top-level _plugins; an external plugin whose name is
not a single lowercase path segment; two external plugins sharing a name; and a
source declaring a ref or a sha, which this gateway does not resolve at —
refused rather than silently resolved somewhere else.
Egress isolation is the control that matters, and it is not in this file
With enabled: true, manifest content — attacker-influenced upstream data —
drives gateway-originated fetches. The allowlists and budgets above are
defence in depth, not the primary control. Run ingestion egress through a
proxy or DMZ with no route to cloud metadata endpoints, internal APIs, or
anything holding corporate credentials. The gateway documents the topology;
the network enforces it. See
Trust boundaries for what the
in-application layer does and does not claim, and
ADR 0011
for why that ordering was chosen.
Vetting¶
The vetter chain that runs at ingestion. Java-side defaults; nothing appears
in application.yaml.
skills-gateway:
vetting:
# How long a single vetter may take before its verdict is recorded as an
# error — which blocks the snapshot. A wedged vetter must never wedge
# ingestion, and a vetter that never answers must never look like a pass.
timeout: 30s
# Files larger than this are handed to vetters unread. They are reported
# in one informational 'file-not-scanned' finding per vetter and reason,
# and in the verdict's coverage summary; never skipped in silence.
max-file-bytes: 1048576
# How much of a snapshot's content one chain run may hold so that the
# vetters after the first read it instead of inflating it again. Past
# this, content is re-read rather than kept: a snapshot larger than the
# bound costs speed, never coverage.
content-cache-bytes: 33554432
# How often lapsed waivers are noted in the audit ledger. This cannot open a
# hole: a waiver stops suppressing its finding the moment the effective
# outcome is next computed, whether or not this sweep has run.
waiver-sweep-interval: 1h
waiver-sweep-batch-size: 200
# The cooling-off window: how long a commit must have been in quarantine
# before it can be approved. 0 — the default — is no window at all.
minimum-release-age: 0s
# The organisation-level license policy, evaluated by the built-in
# license-scan vetter. Both lists default to empty, under which
# identified licenses are informational and an unknown or missing license
# only warns — an upgrade blocks nothing.
license:
# SPDX ids. Once non-empty, any license not on the list — and any
# unknown or missing license — is a blocking finding.
allowed: [MIT, Apache-2.0, BSD-3-Clause]
# SPDX ids whose detection is a blocking finding. Checked before the
# allow list: a license on both is reported as banned.
banned: [AGPL-3.0]
# The posture of the built-in skill-conformance vetter, which validates
# every SKILL.md against the Agent Skills specification vendored in the
# gateway. Advisory by default: defects are recorded and shown to the
# reviewer, and block nothing.
conformance:
# true makes every conformance defect a blocking finding, waivable like
# any other. A verdict covers a whole snapshot, so one malformed skill
# then holds up every other skill in the marketplace.
enforce: false
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.vetting.timeout |
duration | 30s |
Per vetter, per run. Exceeding it is an ERROR verdict, which blocks. |
skills-gateway.vetting.max-file-bytes |
integer | 1048576 |
Zero or negative falls back to the default. |
skills-gateway.vetting.content-cache-bytes |
integer | 33554432 |
Content one chain run may retain for reuse across its vetters. Zero or negative falls back to the default. |
skills-gateway.vetting.waiver-sweep-interval |
duration | 1h |
How often waiver-expired ledger entries are written. Has no effect on the gate. |
skills-gateway.vetting.waiver-sweep-batch-size |
integer | 200 |
Lapsed waivers recorded per pass. |
skills-gateway.vetting.minimum-release-age |
duration | 0s |
How long the gateway must have held a commit before it may be approved. 0 disables the gate. |
skills-gateway.vetting.license.allowed |
string list | empty | SPDX ids, case-insensitive. Empty means no allow list is enforced. |
skills-gateway.vetting.license.banned |
string list | empty | SPDX ids, case-insensitive. Evaluated before the allow list. |
skills-gateway.vetting.conformance.enforce |
boolean | false |
Whether SKILL.md conformance defects block approval instead of warning. |
Minimum release age¶
The gate refuses POST /api/v1/snapshots/{id}/approve with 409 while the
snapshot is younger than this, and the problem document names the setting, the
snapshot's current age and the time remaining. Nothing has to run for the wait
to end: the age is compared at the instant of each approval request, exactly as
waiver expiry is, so a snapshot becomes approvable on its own.
The clock is the gateway's first sighting, not the commit's date
The name mirrors Renovate's, but what is measured is ingestion age: the instant this gateway first ingested that commit, recorded when the snapshot row was created. The commit's own author and committer dates are never read — they are written by whoever made the commit, so a control that trusted them could be defeated by backdating one.
Re-ingesting the same commit does not restart the clock: ingestion recognises the SHA and keeps the existing snapshot, so a re-push cannot reset the window either.
The rejection path is not gated — suspicious content can always be refused at once — and neither is anything about serving already-approved content.
There is deliberately no exemption and no per-approval override, including for the first snapshot of a newly registered marketplace: an exemption is a special case an attacker can arrange to land in. Getting an urgent fix out before the window elapses means changing this setting, which is a deployment someone reviews.
The license policy is configuration on purpose
Vetting policy must be attributable per chain run: the license-scan
vetter stamps a digest of these lists into its recorded version, so a
changed answer about unchanged content can be traced to the policy change
that caused it. That is why the lists change by deploy, not by API — see
License compliance for skills. After
changing them, trigger a re-vet to turn the new policy into fresh evidence.
There is no switch that turns vetting off
Deliberately. A snapshot with no chain run is blocked either way, so a kill switch would buy an estate of blocked snapshots with no findings to explain them. To get past a vetter that is wrong about a snapshot, waive the findings it raised — scoped, justified and expiring, and on the record.
Conformance enforcement is a decision, not a default
conformance.enforce is false out of the box, and an upgrade therefore
blocks nothing: the skill-conformance vetter records every departure
from the pinned Agent Skills specification as a warning the reviewer sees.
Turning it on makes those same departures blocking — a snapshot with one
malformed SKILL.md then needs a waiver per finding before any of its
skills can be published, and a SKILL.md too large to read blocks too,
because under enforcement "we could not check" must not read as "it
conformed". Watch the advisory findings for a cycle first; they are exactly
the set that would block.
Like the license lists, the posture is stamped into the vetter's recorded version, so every run names the posture it ran under. Changing it is a deploy, and a re-vet turns the new posture into fresh evidence.
A shortened timeout silently converts slow vetters into blockers
Lowering timeout does not make vetting faster; it makes slow vetters
fail. A vetter that times out is recorded as ERROR and blocks the
snapshot, so every affected approval then needs a vetter-error waiver —
which is a reviewer writing down that the vetter never looked.
The chain, the verdict states, the aggregation rule, waivers, and the honest limits of the built-in vetters are described in Vetting — the vetter chain.
External connectors¶
An operator can extend the chain with their own vetters — an LLM reviewer, a
sandbox, a corporate scanner — under skills-gateway.vetting.external. Each
entry becomes a vetter at its configured order, running against the
quarantined snapshot alongside the built-ins and recorded by the same
fail-closed rules. An empty or absent list changes nothing.
skills-gateway:
vetting:
external:
- name: llm-review # stable identity, recorded on every verdict;
# must be unique and not a built-in name
url: https://vet.internal/api/v1/vet # http/https endpoint the gateway POSTs to
order: 150 # chain position; the built-ins are at 100+
version: "2024-06" # rule-set identity, stamped into the chain identity
description: IT Security LLM review of skill instructions
# Credential sent to the endpoint. Reference an environment variable —
# never inline a literal. Write-only: never logged, audited or echoed.
token: ${VETTING_LLM_TOKEN}
token-header: Authorization # default; any header name is accepted
token-scheme: Bearer # applied only for the Authorization header
connect-timeout: 5s
read-timeout: 30s
# Caps that keep a hostile or broken endpoint from exhausting memory, and
# a too-large snapshot from earning a verdict about partial content.
max-request-bytes: 5242880
max-response-bytes: 1048576
max-file-bytes: 1048576
The gateway POSTs {snapshotId, marketplace, sha, files[]} — the snapshot's
scannable file content, since quarantined content is never served — and expects
back a normalized {state, reportUrl, findings[]} where state is one of
pass, warn, fail or pending, and each finding carries id, severity
(info|low|medium|high|critical), optional location and a message.
| Property | Type | Default | Notes |
|---|---|---|---|
…external[n].name |
string | — | Required, unique; must not be a built-in (secret-scan, prompt-injection, executable-surface, license-scan, skill-conformance). |
…external[n].url |
url | — | Required; http or https only. |
…external[n].order |
integer | 1000 |
Chain position; ties broken by name. Built-ins start at 100. |
…external[n].version |
string | 1 |
Stamped into the chain identity (GW_VETTING_0012). Bump when the external rules change. |
…external[n].description |
string | derived | Reviewer-facing one-liner. |
…external[n].token |
string | none | Credential; use ${ENV}. Write-only. Null sends no credential. |
…external[n].token-header |
string | Authorization |
Header the credential is sent in. |
…external[n].token-scheme |
string | Bearer |
Prefix, applied only for Authorization. Blank sends the raw value. |
…external[n].connect-timeout |
duration | 5s |
Exceeding it is an ERROR verdict, which blocks. |
…external[n].read-timeout |
duration | 30s |
Exceeding it is an ERROR verdict, which blocks. |
…external[n].max-request-bytes |
integer | 5242880 |
A snapshot whose scannable content exceeds this fails closed rather than shipping partial evidence. |
…external[n].max-response-bytes |
integer | 1048576 |
A larger response fails closed. |
…external[n].max-file-bytes |
integer | 1048576 |
Per-file cap on content in the bundle; a larger file is sent unscanned, not dropped. |
An external connector fails closed, hard
Every way the endpoint can fail to return a verdict the gateway can stand
behind — unreachable, connection refused, timeout, non-2xx, empty, oversized,
unparseable, an unrecognised state, or a malformed finding — is recorded as
an ERROR verdict, which blocks the snapshot. A flaky external endpoint
blocks approvals; it never lets content through. The recorded state is also
the worse of the state the endpoint declared and the state its own
findings imply, so an endpoint cannot return pass alongside a critical
finding and have it pass.
External connectors are configuration for the same reason the license policy is
Their identity and version are stamped into every run's chain identity
(GW_VETTING_0012), so a changed answer about unchanged content is attributable. That
is why they are declared by deploy, not managed through the API. A pending
answer is the asynchronous seam: it is recorded as PENDING and blocks
until resolved — the inbound resolution callback is a separate, later
capability.
See Adding an external vetter for the wire contract in full and a minimal working example.
Continuous re-vetting¶
Re-running the chain over content that is already approved and served, and
what a fresh violation on it does. Java-side defaults; nothing appears in
application.yaml.
skills-gateway:
vetting:
revet:
# Whether the scheduled sweep runs. ON by default: it only ever produces
# evidence — a new chain run over pinned content — and never retracts
# anything on its own. The on-demand endpoints work regardless.
enabled: true
# WARN (default): a violation is recorded and announced in full, and the
# snapshot stays approved and published.
# ENFORCE: a violation revokes the snapshot and removes its published refs.
mode: warn
# How often the sweep runs.
interval: 6h
# A snapshot is re-vetted only when its newest run is older than this. With
# batch-size, this is what stops a tick re-vetting the whole estate: the
# sweep takes the oldest-vetted snapshots first and covers the rest in
# rotation over later ticks.
cadence: 24h
batch-size: 25
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.vetting.revet.enabled |
boolean | true |
Runs the scheduled sweep. POST /api/v1/snapshots/{id}/revet and POST /api/v1/marketplaces/{name}/revet are unaffected. |
skills-gateway.vetting.revet.mode |
warn | enforce |
warn |
What a violation does. warn never unpublishes anything. |
skills-gateway.vetting.revet.interval |
duration | 6h |
Sweep schedule. |
skills-gateway.vetting.revet.cadence |
duration | 24h |
Minimum age of a snapshot's newest run before the sweep picks it again. |
skills-gateway.vetting.revet.batch-size |
integer | 25 |
Approved snapshots re-vetted per pass. |
enforce unpublishes content teams are already using
Under enforce, a re-vetting violation revokes the snapshot and removes
the refs the facade serves it through — with no person in the loop. The
next git fetch by every consuming team fails. Run warn for at least one
full sweep cycle first and read the revet-violation ledger entries: each
one names the identities that had already fetched the snapshot, which is
the blast radius enforce would have caused.
A broken vetter never revokes anything
A run that blocks only because a vetter errored, timed out, or has not answered is recorded as inconclusive, and leaves the snapshot approved and served in either mode. Retraction needs a vetter that objects to the content. This does not loosen the approval gate: an inconclusive run still blocks approving, re-approving or publishing that snapshot.
The sweep, the revoked state, and the re-approval path are described in Re-vetting approved content.
Separation of duties (four-eyes)¶
Whether the identity that supplied a snapshot may also be the one that approves
it. Java-side defaults; nothing appears in application.yaml.
skills-gateway:
approval:
four-eyes:
# WARN (default): a conflict is recorded on the audit ledger and the
# approval proceeds.
# ENFORCE: the approval is refused, and the snapshot stays held.
mode: warn
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.approval.four-eyes.mode |
warn | enforce |
warn |
What a detected conflict does. warn never refuses an approval; enforce refuses it and publishes nothing. |
A reviewer conflicts with a snapshot when they are any of:
| Conflict | Meaning |
|---|---|
registered-by |
They registered the marketplace the snapshot came from. |
ingested-by |
They triggered the ingestion that pinned it. |
waiver-author |
They wrote a waiver this approval relies on — the response names which one. |
The two automated sync triggers, scheduler and webhook, are recorded as the
ingestion actor but are never conflicts: a scheduled poll is nobody's judgement
about the content. Neither is an unrecorded actor, which is what marketplaces
and snapshots that predate this release carry.
There is no way to switch detection off
warn is the floor, not a disabled state. Whatever the mode, every
detected conflict is appended to the audit ledger as a four-eyes-conflict
entry naming the acting identity, the snapshot, the mode, whether the
approval proceeded, and each conflicting act. That is what makes
warn measurable rather than merely permissive — and what lets an operator
size up enforce from evidence before turning it on.
enforce needs at least two principals who can approve
Under enforce a marketplace whose only approver also registered it, or
ingests its content, has no one left who may publish it. Before switching,
check that every marketplace that needs deciding has a second identity with
approval rights — a second admin, or an approver scoped to that marketplace
under delegated administration. This is why the
default is warn: a single-administrator deployment must keep working
across an upgrade.
Identities are compared as exact strings, as the identity provider reports them through the configured principal claim. The comparison assumes what is true of a single provider — that one person is one principal string — and makes no attempt to reconcile two spellings of the same human.
Refusals are visible to reviewers before they act: the portal's approve dialog
says which acts conflict, and GET /api/v1/snapshots/{id}/four-eyes answers the
same question for the calling identity. Rejecting a snapshot is never gated —
refusing content quickly must not need a second pair of eyes.
The rule as part of the approval boundary is described in Approving snapshots.
Plugin-name collisions¶
Whether an approval is refused when the snapshot introduces a plugin name that
looks like one another marketplace already serves. Java-side default; nothing
appears in application.yaml.
skills-gateway:
approval:
name-collision:
# true (default): a lookalike of an approved plugin in another marketplace
# is refused until a waiver on rule plugin-name-collision covers it.
enabled: true
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.approval.name-collision.enabled |
boolean | true |
false refuses nothing and reports nothing. |
On by default, because enabling it withdraws nothing: only names new to a marketplace are checked, only the snapshot seeking approval is judged, and an estate that already contains lookalikes keeps serving them. What it matches is described in Vetting.
Switching it off leaves typosquatting uncovered
The switch exists for an estate built on deliberate forks, where every lookalike is legitimate and a refusal would only be waived unread. Anywhere else, accept a fork with a waiver rather than turning the rule off.
Retention¶
Snapshot retention: which snapshots the gateway may delete, and when the two
passes run. Java-side defaults; nothing appears in application.yaml.
skills-gateway:
retention:
# OFF by default. Retention only ever deletes because an operator asked for
# it; an upgrade never starts deleting on its own. The on-demand endpoints
# work regardless, so a policy can be inspected before this is flipped.
enabled: false
# Evaluation marks (soft delete); compaction removes (hard delete). They are
# separate schedules because the restore window only means anything if the
# two are decoupled in time.
poll-interval: 1h
compaction-interval: 6h
# Snapshots considered per marketplace per pass.
batch-size: 200
# How long an abandoned publication staging ref must have been under
# observation before compaction may remove it. Not a tuning knob: it is what
# keeps the sweep from overtaking a publication that is still transferring
# objects. Set it above your slowest publication. Zero or negative switches
# the sweep off rather than sweeping everything.
staging-ref-max-age: 24h
# The policy every marketplace inherits.
defaults:
# A snapshot still 'held' this long after ingestion is eligible.
# Zero or negative disables the criterion rather than selecting everything.
held-max-age: 90d
# Whether a held/rejected snapshot overtaken by a later approved snapshot
# of the same marketplace is eligible.
superseded: true
superseded-min-age: 30d
# Veto, not a selector: a candidate whose SHA was fetched through the
# facade within this window is dropped from the pass.
min-idle: 30d
# How long a soft-deleted snapshot stays restorable before compaction may
# remove it. Resolved at deletion time, not at compaction time.
restore-window: 14d
# Per-marketplace overrides; unset fields fall back to defaults.
marketplaces:
acme:
held-max-age: 30d
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.retention.enabled |
boolean | false |
Runs the scheduled passes. The on-demand endpoints are unaffected. |
skills-gateway.retention.poll-interval |
duration | 1h |
Evaluation (soft delete) interval. |
skills-gateway.retention.compaction-interval |
duration | 6h |
Compaction (hard delete) interval. |
skills-gateway.retention.batch-size |
integer | 200 |
Snapshots per marketplace per pass. Zero or negative falls back to the default. |
skills-gateway.retention.staging-ref-max-age |
duration | 24h |
How long an abandoned publication staging ref must be observed before compaction removes it. Zero or negative disables the sweep. |
skills-gateway.retention.ledger-max-age |
(unset) | How old an audit-ledger read entry must be before the compaction pass may remove it. Unset, zero or negative switches the trim off, and it removes nothing regardless unless an enabled export sink has already taken the entries — see Bound the audit ledger. | |
skills-gateway.retention.defaults.held-max-age |
duration | 90d |
Zero or negative disables the criterion. |
skills-gateway.retention.defaults.superseded |
boolean | true |
Enables the supersession criterion. |
skills-gateway.retention.defaults.superseded-min-age |
duration | 30d |
Minimum age of a superseded snapshot. |
skills-gateway.retention.defaults.min-idle |
duration | 30d |
Fetch-recency veto window. |
skills-gateway.retention.defaults.restore-window |
duration | 14d |
Restore window applied at deletion time. |
skills-gateway.retention.marketplaces.<name>.* |
policy | (empty) | Same keys as defaults; unset fields inherit. |
Enabling retention starts deleting on the next pass
Preview first: GET /api/v1/retention/candidates shows exactly what the
policies in force would select, and writes nothing. Approved snapshots are
never eligible, but a generous held-max-age can still clear a large
review backlog on the first pass.
Compaction cannot be undone
A soft-deleted snapshot is restorable only until its purge_after. After
compaction the row and the unreachable git objects are gone, and only the
ledger entry remains. Size restore-window so a mistake is noticed inside
it.
Criteria, guards and the two passes are described in Snapshot retention.
Upstream sync¶
How automated ingestion behaves for marketplaces whose sync mode is
scheduled or webhook. Java-side defaults; nothing appears in
application.yaml.
skills-gateway:
sync:
# Whether the scheduled polling sweep runs. ON by default, and still safe
# on upgrade: the sweep only touches marketplaces an operator has
# explicitly moved to the `scheduled` sync mode, so an estate of defaults
# (all on-demand) sees no change. The inbound webhook endpoint and the
# sync-mode endpoint work regardless.
enabled: true
# How often the sweep runs, and how many scheduled marketplaces one pass
# ingests — least recently attempted first, so a large estate is covered
# in rotation rather than all at once.
poll-interval: 10m
batch-size: 10
# Inbound webhook bodies larger than this are rejected with 413 before the
# HMAC is computed, bounding the work an unauthenticated caller can cause.
max-webhook-body-bytes: 1048576
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.sync.enabled |
boolean | true |
Runs the scheduled sweep. Touches only scheduled-mode marketplaces. |
skills-gateway.sync.poll-interval |
duration | 10m |
Sweep schedule. |
skills-gateway.sync.batch-size |
integer | 10 |
Scheduled marketplaces ingested per pass, oldest attempt first. |
skills-gateway.sync.max-webhook-body-bytes |
long | 1048576 |
Inbound webhook body bound; larger requests get 413 unverified. |
Modes, the webhook secret lifecycle, and the outage guarantee are described in Syncing from upstream automatically.
Virtual catalog¶
The synthesized one-URL catalog of the whole served estate. Java-side defaults;
nothing appears in application.yaml.
skills-gateway:
catalog:
# Whether approvals and revocations rebuild the catalog and the /api/v1/catalog
# endpoints answer. Turning this off never deletes an existing catalog repo.
enabled: true
# The catalog's facade path (/git/{name}) and its RESERVED name: a
# marketplace cannot be registered under it.
name: catalog
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.catalog.enabled |
boolean | true |
Gates rebuild triggers and the API endpoints. |
skills-gateway.catalog.name |
string | catalog |
Facade path segment; reserved at registration. |
Composition, freshness, and provenance are described in The virtual catalog.
Access tokens¶
Token policy (GW_AUTH_0007). Java-side default; nothing appears in
application.yaml.
skills-gateway:
tokens:
# The longest lifetime creation accepts. When set, a request beyond it —
# including one with no expiry at all — is refused with 422, never
# silently shortened. Unset (the default) accepts tokens that never
# expire, which is what every pre-cap deployment had.
max-ttl: 90d
# What a session-derived credential is GRANTED (GW_AUTH_0018), as opposed to
# what a holder may ask for. Not derived from max-ttl on purpose: a
# deployment may allow year-long CI tokens and still want a credential
# minted from a browser session to die at the end of the working day.
session-ttl: 8h
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.tokens.max-ttl |
duration | unset (unlimited for personal access tokens; 90 days for machine API credentials) | Cap on accepted token lifetime; refusal, not clamping. |
skills-gateway.tokens.session-ttl |
duration | 8h |
Lifetime granted to a session-derived credential. The caller cannot influence it. |
A machine API credential is always capped, even with max-ttl unset
A machine API credential must state
an expiry, but "mandatory expiry" alone admits now + 100 years whenever no
cap is configured — which is the never-expiring credential in a CI variable,
spelled differently. Machine credentials are therefore held to a built-in
cap of 90 days when max-ttl is unset.
Ninety days is a quarter: short enough that a forgotten credential expires
within one planning cycle rather than outliving the service it was minted
for, and long enough that rotating it is a scheduled chore rather than an
interruption. A configured max-ttl, longer or shorter, always wins — and
that is the point. A long-lived control-plane credential should be a stated
choice, not the consequence of leaving a property blank.
The cap does not change personal access tokens, whose behaviour under an
unset max-ttl is exactly what it was.
Scopes, expiry, and rotation are described in Access tokens; session-derived credentials in Consuming skills.
Delegated administration¶
Role enforcement for the web surface (GW_AUTH_0010, GW_AUTH_0013). Java-side defaults;
nothing appears in application.yaml.
Enforcement is unconditional (GW_AUTH_0025): every mutation and the ledger surface need a role, in every deployment. There is nothing to switch on.
skills-gateway.roles.enabled was removed, and setting it refuses startup
It used to switch enforcement off, and defaulted to off. A gateway that
was installed and left alone therefore granted full administrative access
to anyone who could complete a login. Enforcement is now unconditional, so
the property has nothing to turn on or off — and it is refused rather
than ignored, under any spelling the framework would resolve
(SKILLSGATEWAY_ROLES_ENABLED included). Quietly doing the opposite of
what a manifest says is not something an operator can see from inside the
deployment.
Remove the property. The refusal is a migration aid and is scheduled for removal at the next major version — see Compatibility and allowlists.
skills-gateway:
roles:
# Admins by configuration: effective without a grant row and unrevocable
# through the API — the escape hatch that survives a bad grant edit.
admins:
- admin@example.com
# Roles from the identity provider's own claims (GW_AUTH_0015.1). The claim name
# and every value are yours: on a shared app registration the values are
# the organisation's group ids or app-role values, not gateway role names.
claim: groups
mappings:
- claim-value: 8f1c0a2e-0000-0000-0000-000000000000
role: admin
- claim-value: gateway-approvers-acme
role: approver
marketplace: acme
- claim-value: security-auditors
role: auditor
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.roles.admins |
list of strings | [] |
Principals that hold the admin role by configuration, matched exactly against the authenticated principal name. Effective without a grant row, and no API call can revoke them. Stored grants and claim-derived roles add to this list; they never replace it. |
skills-gateway.roles.claim |
string | groups |
Claim carrying membership. A dotted path walks nested claims (realm_access.roles). |
skills-gateway.roles.mappings |
list | [] |
Claim value → role. approver names a marketplace; admin and auditor must not. |
Naming an administrator in roles.admins, in roles.mappings, or in
estate.grants is required: enforcement is unconditional, so a gateway
that names none could not be administered once it started, and it refuses to
start instead. A grant made through the API does not satisfy the check —
it is revocable through the API, so it says nothing about the next start.
admins or mappings?¶
Both name administrators, and neither replaces the other.
roles.admins |
roles.mappings |
|
|---|---|---|
| Matches on | the authenticated principal name, exactly | a claim value the provider emits, exactly |
| Granularity | individuals only — a group name is not a principal name and would match nobody | whatever the claim carries, so typically a whole group or app role at once |
| Roles it can confer | admin only |
admin, auditor, and approver scoped to one marketplace |
| Changing who holds it | edit configuration, restart | change directory membership; no gateway change |
/api/v1/me source |
config |
claim |
| Survives a directory outage, an unassigned role or a wrong claim name | yes | no |
Prefer mappings for everyone whose access the directory already governs —
the joiner/mover/leaver process then governs gateway access too, and there is
no second list to keep in step. Keep at least one entry in admins regardless:
it needs no group, no token and no directory, and it is what gets you back in
when a group is renamed, a claim stops being emitted or a mapping is wrong.
The third source, a row in the grants API, is described in Delegated administration; a session's effective roles are the union of all three.
Claim values are matched exactly, after trimming surrounding whitespace: no
prefix, glob or case-insensitive matching, because a looser match can only ever
widen who is privileged. The claim may be a list of strings or a single string;
a delimited string is one value and is never split. A malformed mapping — an
unknown role, a blank value, an unscoped approver, a scoped global role —
refuses startup rather than silently granting nothing. A mapping may name a
marketplace that is not registered yet; until it is, the mapping matches
nothing.
Claims are read only from a browser session established through the identity
provider. A personal access token on the git facade, the dev-insecure-auth
principal and the anonymous webhook request carry no claims and derive no role,
whatever authorities they hold.
The roles, the enforcement matrix, and the staging workflow are described in Delegated administration; the mapping walkthrough in Identity providers; the grants API in Roles.
Declarative estate¶
The estate defined as configuration (GW_ESTATE_0001–GW_ESTATE_0005, GW_APPROVAL_0006): marketplaces,
role grants, webhook subscribers, audit export sinks and policy deny rules,
reconciled at startup —
after schema migration, before the web surface serves — and on demand via
POST /api/v1/estate/reconcile. Empty by default; an empty
declaration reconciles nothing.
Reconciliation is additive and idempotent. A declared object is created if
missing and converged if drifted; an object absent from the declaration is
never deleted, deregistered or revoked — removing a line from this block
retracts nothing. A converged estate reconciles with zero writes and zero
ledger entries; every applied change lands on the append-only ledger under the
same event name as its API equivalent, attributed to the actor
config-reconciler. An entry that fails validation is skipped and reported —
in the log at ERROR, on the ledger as estate-reconciliation-failed, and in
the reconciliation report — and never prevents startup or the
other entries.
skills-gateway:
estate:
# Registered through the exact same gate as POST /api/v1/marketplaces:
# name rules, the reserved catalog name, the URL scheme allowlist.
# There is no ref key — the ingested ref is always the gateway's
# decision (the upstream default branch).
marketplaces:
- name: corp-marketplace
url: https://github.com/acme/skills-marketplace.git
# on-demand or scheduled; applied through the same audited path as
# PUT /api/v1/marketplaces/{name}/sync. Omit to leave the stored mode
# alone. webhook mode is refused: its inbound HMAC secret is
# generated and shown once, which has no declarative form.
sync-mode: scheduled
# A gateway-hosted marketplace declares no url and is published to by
# pushing (GW_FACADE_0006). Its sync mode is fixed at on-demand: the push is
# its ingestion trigger. push-policy defaults to append-only.
- name: platform-skills
origin: hosted
push-policy: append-only
# The exact shape of POST /api/v1/roles: approver grants name one
# marketplace that must exist at reconcile time (declared above, or
# registered through the API); admin and auditor grants must not.
grants:
- principal: alice@example.com
role: approver
marketplace: corp-marketplace
- principal: audit@example.com
role: auditor
# Webhook subscribers with an OPERATOR-SUPPLIED signing secret — the
# inversion of the API's generated show-once secret. Reference an
# environment variable; never inline a literal in a committed file.
webhooks:
- name: ci-bot
url: https://ci.example.com/hooks/skills-gateway
events: # omit for all events
- marketplace.snapshot.approved
- marketplace.snapshot.rejected
secret: ${SGW_ESTATE_CI_BOT_SECRET}
# Audit export sinks; same secret contract as webhooks.
audit-sinks:
- name: siem
url: https://siem.example.com/ingest/skills-gateway
secret: ${SGW_ESTATE_SIEM_SECRET}
after: 0 # cursor seed — applied at creation ONLY
batch-size: 500
# CEL policy deny rules, through the same compiled, audited path as
# POST /api/v1/policy/rules: an expression that does not compile to a
# boolean is an isolated entry failure, never a stored rule.
policy-rules:
- name: no-shell-tools
description: deny skills declaring shell tools
expression: 'skills.exists(s, s.tools.exists(t, t.startsWith("Bash")))'
# enabled defaults to true: a declared rule is declared to enforce
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.estate.marketplaces |
list | [] |
Each entry: name, url, optional sync-mode (on-demand/scheduled). |
skills-gateway.estate.marketplaces[].name |
string | — | Same rules as the API: ^[a-z0-9][a-z0-9_-]{0,62}$ (at most 63 characters), catalog name reserved. |
skills-gateway.estate.marketplaces[].url |
string | — | Scheme must be allowlisted. Immutable once registered: a differing declared URL is a reconciliation failure, never an update. |
skills-gateway.estate.marketplaces[].sync-mode |
string | unset (not managed) | on-demand or scheduled; webhook is refused. Unset never touches the stored mode. |
skills-gateway.estate.grants |
list | [] |
Each entry: principal, role (admin/approver/auditor), marketplace (required for approver, forbidden otherwise). |
skills-gateway.estate.webhooks |
list | [] |
Each entry: name, url, optional events (a list; omitted means every event), secret. |
skills-gateway.estate.webhooks[].secret |
string | — | Operator-supplied, minimum 16 characters, write-only. A changed value rotates the stored secret idempotently. |
skills-gateway.estate.audit-sinks |
list | [] |
Each entry: name, url, secret, optional after, optional batch-size. |
skills-gateway.estate.audit-sinks[].after |
long | 0 |
Seeds the ledger cursor at creation only; never re-applied to an existing sink. |
skills-gateway.estate.audit-sinks[].batch-size |
int | audit-export default | Converged when set; unset never touches the stored value. |
skills-gateway.estate.policy-rules |
list | [] |
Each entry: name, optional description, expression, optional enabled. See Policy deny rules. |
skills-gateway.estate.policy-rules[].name |
string | — | Same rules as the API: ^[a-z0-9][a-z0-9_-]*$, at most 100 characters. |
skills-gateway.estate.policy-rules[].expression |
string | — | CEL over the policy variables; compiled to a boolean at reconcile time — a non-compiling expression is an isolated entry failure. |
skills-gateway.estate.policy-rules[].enabled |
boolean | true |
Enabled rules gate every approval fail-closed; description, expression and this flag are converged on drift. |
Declared secrets are sensitive property values
The gateway treats a declared secret as write-only — it never appears in
the log, the ledger, the reconciliation report, or any API response — but
it cannot control where the configuration lives. Supply secrets by
environment-variable reference (${VAR}) from a secret store; a literal in
a committed values file is a leaked credential. A blank or shorter-than-16
secret is refused as a reconciliation failure.
Personal access tokens are deliberately not declarable: they are user-owned credentials, API-only by design. The GitOps workflow is described in Declarative estate configuration; the report and trigger endpoints in Estate.
Datasource¶
Not present in application.yaml at all — supplied purely by environment.
PostgreSQL is assumed.
$ export SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/skillsgateway
$ export SPRING_DATASOURCE_USERNAME=skillsgateway
$ export SPRING_DATASOURCE_PASSWORD=skillsgateway
The Helm chart exposes these as postgresql.host (required, no default),
postgresql.port (5432), postgresql.database and postgresql.username
(both skillsgateway), and postgresql.existingSecret (required — a Secret
with a password key).
Flyway is deliberately unconfigured
There are no spring.flyway.* settings. The schema is built from a single
consolidated migration on startup, because every environment — including
Testcontainers and the e2e compose stack — builds it from scratch.
That the schema is one migration rather than a chain is a consequence of
being pre-1.0: with no compatibility promised between versions, there is no
deployed database to upgrade, so a schema change edits V1__init.sql
instead of adding a version behind it. At 1.0.0 that reverses — V1 freezes
and changes become incremental migrations.
OIDC login¶
The registration id is idp. The defaults below are placeholders rather than
absent values, so the registration always exists and a gateway with none of these
set still starts — it simply cannot complete a login.
| Property | Environment variable | Placeholder default |
|---|---|---|
…client.registration.idp.client-id |
SGW_OIDC_CLIENT_ID |
change-me |
…client.registration.idp.client-secret |
SGW_OIDC_CLIENT_SECRET |
change-me |
…client.provider.idp.authorization-uri |
SGW_OIDC_AUTHORIZATION_URI |
https://idp.invalid/authorize |
…client.provider.idp.token-uri |
SGW_OIDC_TOKEN_URI |
https://idp.invalid/token |
…client.provider.idp.jwk-set-uri |
SGW_OIDC_JWK_SET_URI |
https://idp.invalid/jwks |
…client.provider.idp.user-name-attribute |
SGW_OIDC_USER_NAME_ATTRIBUTE |
sub |
…client.registration.idp.scope |
SGW_OIDC_SCOPE |
openid |
…client.registration.idp.redirect-uri |
SGW_OIDC_REDIRECT_URI |
{baseUrl}/login/oauth2/code/idp |
user-name-attribute decides what the principal is called everywhere else —
grants, roles.admins, and every ledger row. On an app registration shared
between services, sub is an opaque per-application identifier, so set this to
a readable claim such as preferred_username there. Widen SGW_OIDC_SCOPE
when your provider needs a scope before it will emit group or role claims.
The grant type is fixed at authorization_code. The redirect URI to register
with the provider is https://<your-host>/login/oauth2/code/idp; by default
the gateway derives it from the request ({baseUrl}), which behind a
TLS-terminating proxy is right only with a
forwarded-header strategy in force. SGW_OIDC_REDIRECT_URI
states it absolutely instead, trusting no header — the escape hatch when the
derivation cannot be made to work, not a substitute for it, since every other
URL the gateway builds from the request stays as the container sees it.
Moving any of these off its placeholder is what tells the gateway an identity
provider exists — and a gateway with one configured refuses to start with
skills-gateway.dev-insecure-auth on. See
skills-gateway above.
Expected issuer¶
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.oidc.issuer |
string | (unset) | ID-token issuer to require. Required once an identity provider is configured: the gateway refuses to start without it. |
The gateway configures its provider endpoints explicitly rather than by issuer
discovery, and Spring Security compares an ID token's iss only when the
registration carries an issuer — so with this unset, nothing checks it. That
matters most where one authorization endpoint serves many tenants: every
tenant's tokens verify against the same signing keys, so the issuer is the only
thing that says which organisation the person logging in belongs to.
A configured provider needs an issuer
The gateway refuses to start when an identity provider is configured —
a client id other than change-me, or a provider endpoint off the
idp.invalid host — and this is unset or blank. The refusal names the
property and the settings that showed a provider to be configured. Set it to
the issuer value of your provider's /.well-known/openid-configuration.
Unset is accepted only while the shipped placeholders are in force, and
under skills-gateway.dev-insecure-auth, whose own guard refuses a
configured provider.
Identity-provider bearer tokens on the facade¶
Off by default. When enabled, /git/** accepts an OIDC access token or ID token
from the identity provider configured above, in addition to a personal access
token — see the facade reference and
ADR 0019.
skills-gateway:
facade:
idp-bearer:
enabled: false
audience: # unset — defaults to the OAuth2 client id
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.facade.idp-bearer.enabled |
boolean | false |
Whether /git/** accepts a bearer token. While false, no decoder, provider or filter is registered at all. |
skills-gateway.facade.idp-bearer.audience |
string | (unset) | The value aud must contain. Unset means spring.security.oauth2.client.registration.idp.client-id. |
There is deliberately no issuer or key-set property here. The key set comes
from the idp client registration's jwk-set-uri and the issuer from
skills-gateway.oidc.issuer, so the facade cannot be configured to accept a
token the browser login would refuse.
Enabling this requires a pinned issuer
With enabled: true and skills-gateway.oidc.issuer unset or blank, the
gateway refuses to start, naming both properties. A signature check alone
cannot separate one tenant of a shared authorization endpoint from another,
and the login path's provenance — the gateway itself exchanged a code over
TLS with a configured endpoint — does not exist for a bearer token arriving
at the facade.
A token is accepted only if its signature verifies against the published key
set, its iss equals the pinned issuer, its aud contains the expected
audience, and the current time is within nbf/exp (60 seconds of clock
leeway). Anything else is refused with the same bare 401 an unknown PAT gets.
Revocation belongs to the identity provider
The gateway cannot revoke a bearer token. Its lifetime is the provider's — usually minutes to an hour — and that short life is the control. A deployment that needs gateway-side revocation keeps using access tokens, which is why both exist.
Session cookie¶
Named rather than left to the browser, because Firefox and Safari apply no default at all: unset, the session cookie rides a cross-site request in those browsers.
Strict is deliberately not used. It withholds the cookie on the identity
provider's redirect back to /login/oauth2/code/idp — a cross-site top-level
navigation that has to carry the session holding the saved authorization request
— so login fails outright. Lax still withholds it on every cross-site POST,
which is the vector. Naming the attribute is one half of GW_AUTH_0030 — A
cross-site request cannot act on an ambient session; the control itself is the
CSRF token on the web chain, described under
The web surface.
Actuator¶
Only two endpoints are exposed. /actuator/health is the only
unauthenticated path in the application; /actuator/sbom serves the CycloneDX
SBOM and requires a session.
Probe the bare health path
The security chain permits exactly /actuator/health. Subpaths such as
/actuator/health/liveness redirect to the identity provider, which is why
both Kubernetes probes point at the bare path.
API documentation¶
scalar:
enabled: true
path: /docs # the Scalar UI
url: /v3/api-docs # the OpenAPI document it renders
theme: purple
with-default-fonts: false
Both paths sit behind the OIDC login like the rest of the web surface.
with-default-fonts is off because Scalar otherwise loads its fonts from
fonts.scalar.com, which the web surface's content security policy refuses.
With it off, the reference renders in the browser's own fonts. The page at
path is the one page whose policy allows inline script, because Scalar starts
itself with one. Moving path moves that exception with it. See
Trust boundaries.
Forwarded headers¶
| Property | Environment variable | Default | Values |
|---|---|---|---|
server.forward-headers-strategy |
SERVER_FORWARDHEADERSSTRATEGY |
(unset) | native, framework, none |
The gateway serves plain HTTP and terminates no TLS, so the request it sees
names its own host and port over http. Every URL it builds from the request —
the OIDC redirect URI above all — is therefore wrong behind a TLS-terminating
proxy until it believes the scheme and host the proxy reports in
X-Forwarded-Proto and X-Forwarded-Host. This is Spring Boot's own setting,
with Spring Boot's own meanings:
| Value | Effect |
|---|---|
native |
Tomcat's RemoteIpValve: the headers are honoured only from a peer whose address is in server.tomcat.remoteip.internal-proxies — by default the private ranges 10/8, 172.16/12, 192.168/16, 100.64/10, loopback and their IPv6 counterparts. X-Forwarded-For also replaces the remote address the fetch ledger records. Recommended. |
framework |
Spring's ForwardedHeaderFilter: the headers, X-Forwarded-Prefix and RFC 7239 Forwarded are honoured from any peer. The remote address is left as the proxy's. |
none |
The headers are ignored. |
| (unset) | native when Spring Boot detects a container platform from the environment (Kubernetes and ECS among them), otherwise none. |
Trusting the headers is a security decision: the gateway cannot tell a proxy's
header from a client's, so framework belongs only where nothing but the proxy
can reach the listener and the proxy overwrites the headers, and native
belongs where the proxy's address is inside the internal ranges. The Helm chart
sets native through its forwardHeadersStrategy value; other runtimes set
the variable — see
Running behind a proxy.
The gateway registers the framework filter itself and reads the setting at
runtime (GW_AUTH_0029 — Proxy-reported scheme and host are honoured only when
configured, identically on every packaging). Spring Boot's own registration is
behind a @ConditionalOnProperty, which an ahead-of-time image evaluates when it
is built, with the property unset — so on the GraalVM native image the release
used to publish it was never compiled in and no runtime value could switch it on.
native never had that problem: Tomcat's valve is installed by a customizer that
reads the property at runtime. The released image is a JVM container today
(ADR 0012 — A JVM container is the release artifact), where neither shape can occur; the explicit
registration stays because it is what makes the setting mean one thing on every
packaging rather than on the one that happens to ship.
Server and storage notes¶
Port. Never set; the Spring default 8080 applies. Compose publishes
8080:8080 and the Helm chart hardcodes containerPort: 8080, so changing
server.port means editing the chart, not just values.
Container storage. The image sets SKILLSGATEWAY_DATADIR=/data — the
relaxed-binding environment form of skills-gateway.data-dir.
JGit's own scratch. The image sets XDG_CONFIG_HOME=/tmp/xdg-config, and
the chart restates it in the Deployment (GW_FACADE_0024 — The embedded git library's
own scratch stays inside the writable temporary directory). JGit resolves a
filesystem-timestamp-resolution cache file there the first time it touches a
repository; left unset, it falls back to a home directory this user does not
have under a read-only root filesystem, and fails — caught, not fatal, but
logged — on every fetch.
Volume durability is a decision the chart makes you state. There is no
default: persistence.mode must be existingClaim (a PersistentVolumeClaim
that already exists), ephemeral (an emptyDir, and everything on it is lost
on restart), or none (no volume at all, accepted only on the object-store
backend). Anything else — including leaving it empty — stops the render with a
message that spells out the consequence. See
Choosing and migrating the storage backend.
What the chart refuses. Alongside the durability choice it will not render
an object-store selection with no bucket or no region, a static credential
mode with no secret to take the keys from, a web-identity mode with no
annotation on the service account, persistence.mode: none on the filesystem
backend, or more than one replica on the filesystem backend. Each refusal names
the value it was decided by.
Background passes coordinate themselves across replicas. Every scheduled pass — sync, re-vetting, retention evaluation and compaction, waiver expiry, webhook dispatch, mirror reconciliation and audit export — takes a lease in the gateway's own database before it runs, so the estate gets one pass per interval whatever the replica count (GW_FACADE_0030 — A scheduled background pass runs on one replica at a time). There is no property for it. A lease lasts its pass's own interval, which is the property already listed for that pass above, so there is nothing extra to set and nothing to keep consistent between replicas. See Running more than one replica.