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.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 and the schedules that apply them. 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. | 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.* |
Role enforcement and the bootstrap admins. Off by default. | No — all defaulted. |
skills-gateway.estate.* |
The declared estate: marketplaces, role grants, webhook subscribers, audit sinks — 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 |
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] |
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 replica may keep serving a reference map it has not
# re-read. This is the upper bound on how long a revoked snapshot can
# still be advertised by a replica that did not do the revoking.
ref-freshness: 10s
# 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.ref-freshness |
duration | 10s |
Upper bound on cross-replica revocation latency. |
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. |
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 snapshot 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. |
skills-gateway.webhooks.timeout |
duration | 10s |
Connect and read timeout per attempt. |
skills-gateway.webhooks.batch-size |
integer | 50 |
Deliveries claimed per pass. |
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.
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/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. |
skills-gateway.audit-export.default-page-size |
integer | 1000 |
Applied when ?limit= is absent. |
skills-gateway.audit-export.max-page-size |
integer | 10000 |
Hard ceiling on ?limit= and on a sink's batch size. |
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.
Vetting¶
The connector chain that runs at ingestion. Java-side defaults; nothing appears
in application.yaml.
skills-gateway:
vetting:
# How long a single connector may take before its verdict is recorded as an
# error — which blocks the snapshot. A wedged connector must never wedge
# ingestion, and a connector that never answers must never look like a pass.
timeout: 30s
# Files larger than this are handed to connectors unread. They are reported
# as an informational 'file-not-scanned' finding, never skipped in silence.
max-file-bytes: 1048576
# 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 connector. 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]
| Property | Type | Default | Notes |
|---|---|---|---|
skills-gateway.vetting.timeout |
duration | 30s |
Per connector, 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.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. |
Minimum release age¶
The gate refuses POST /api/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
connector 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 connector that is wrong about a snapshot, waive the findings it raised — scoped, justified and expiring, and on the record.
A shortened timeout silently converts slow connectors into blockers
Lowering timeout does not make vetting faster; it makes slow connectors
fail. A connector that times out is recorded as ERROR and blocks the
snapshot, so every affected approval then needs a connector-error waiver —
which is a reviewer writing down that the scanner never looked.
The chain, the verdict states, the aggregation rule, waivers, and the honest limits of the built-in scanners are described in Vetting — the connector chain.
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/snapshots/{id}/revet and POST /api/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 connector never revokes anything
A run that blocks only because a connector errored, timed out, or has not answered is recorded as inconclusive, and leaves the snapshot approved and served in either mode. Retraction needs a connector 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/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.
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
# 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. |
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/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/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_0065). 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_0104), 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_0068, GW_0071). Java-side defaults;
nothing appears in application.yaml.
skills-gateway:
roles:
# Off by default: every authorization check passes and an upgrade never
# locks anyone out. Once true, enforcement is deny-by-default: every
# mutation and the ledger surface need a role.
enabled: true
# 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_0098). 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.enabled |
boolean | false |
With false every check passes; grants stay writable as staging data. |
skills-gateway.roles.admins |
list of strings | [] |
Principals that are admins by configuration; DB grants add to them. |
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. |
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_0083–GW_0087, GW_0089): 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/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/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/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_0101). 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/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: snapshot.approved,snapshot.rejected # omit for all events
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/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_-]*$, 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, 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.
OIDC login¶
The registration id is idp and must exist at AOT build time, because
native-image evaluates auto-configuration conditions then. That is why the
defaults are the placeholders below rather than being absent.
| 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 |
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.
Fixed, not intended for override: grant type authorization_code, redirect URI
{baseUrl}/login/oauth2/code/idp. Register that redirect URI with your
identity provider.
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. Unset means the issuer is not compared at all. |
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. The
gateway logs a warning at startup while it is unset.
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
Both paths sit behind the OIDC login like the rest of the web surface.
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.
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, more than one replica on the filesystem backend, or more than one
replica while any of the background pollers is still enabled. Each refusal
names the value it was decided by.