Repository navigation
v1.0.0
·
806 commits
to main
since this release
Immutable
release. Only release title and notes can be modified.
First production release. envisia-forge is dual-licensed under the GNU AGPL
v3 (AGPL-3.0-only, see LICENSE) or the envisia license, at the user's
choice. Install it on an empty PostgreSQL database;
docs/deployment.md
lists the required settings.
Features
- NuGet V3 registry with three repository kinds:
- hosted: push, restore, unlist/relist and delete your own packages;
- proxy: on-demand caching of an upstream V3 feed (nuget.org or a
private feed with Basic/PAT credentials), with an optional publication
delay and negative caching; - group: one feed URL over several members, by priority. A failing
member is tolerated, and a dependency-confusion guard keeps public feeds
from shadowing internal package ids.
- Storage: content-addressed blobs on any S3-compatible store; metadata in
PostgreSQL; race-free garbage collection with an outbox and a reconciler. - Access control: fine-grained RBAC with users, nested groups, roles,
repository grants, and package/content selectors, enforced on every read
and publish path. Tokens can be scoped per repository. - Authentication: ordered, web-managed auth realms (local passwords,
OIDC / Microsoft Entra, and credential forwarding to an existing registry),
an identifier-first login, and API keys. - Trusted publishing: CI jobs on GitHub Actions, GitLab CI and compatible
issuers publish with their workload OIDC token, with no stored API key. See
docs/trusted-publishing.md. - Retention: policies with preview, run and audit, and download tracking.
- Secret encryption at rest: double-envelope encryption of upstream
passwords and realm secrets, with key rotation. - Web UI: browse, repository and package detail, self-service tokens, and
administration of repositories, users, tokens and realms. It is served by
the same process on the same origin. - Operations:
/healthz,/readyzand/version, graceful SIGTERM,
supervised background workers, an audit log of every package change and
admin action (theforge::auditlog target), and an optional Redis L2
cache with cross-instance invalidation. - Container image: signed with cosign, with a provenance attestation and
an SBOM that lists the Debian packages, Rust crates and npm packages it
ships. The image carriesLICENSEand the third-party notices in
/usr/share/doc/forge/. Each release's notes print the exact
verification command. - Kubernetes manifests in
deploy/kubernetes: a kustomize base for the
server, components for an Ingress or Gateway API HTTPRoute, in-cluster
Redis, PostgreSQL and S3 (versitygw), two replicas with a
PodDisruptionBudget, and following:latest; plus a production and an
evaluation overlay. The base pins the image to the release it is tagged
with. - Helm chart in
deploy/helm/forge, published with every release to
oci://ghcr.io/schmitch/charts/forge(same version as the image, signed
with cosign, and attached to the GitHub release). The published chart pulls
the release image by the digest the release workflow signed. Forge's whole
configuration is settable asconfig(rendered toforge.toml), secrets
through a chart-managed (<fullname>-secret-env) or existing Secret;
routing through a Gateway API HTTPRoute and an optional ListenerSet (plus
a cert-manager Certificate); PodDisruptionBudget, HorizontalPodAutoscaler,
NetworkPolicy andextraObjects; and optional in-cluster PostgreSQL,
Redis and versitygw dependencies.
Security
Signing in
- Passwords are verified behind a bounded queue
(auth.password_verify_concurrency). A memory-bounded login lockout
refuses usernames over 256 bytes, keys IPv6 clients by their /64, and backs
off a username guessed from many networks, except from networks it signed
in from in the last 30 days. Unknown and federated usernames take the same
time, so response timing does not reveal which accounts exist. - Usernames are canonical (trimmed, Unicode NFC, lowercased) in every login
path, the lockout, provisioning and admin user creation. Empty names,
control characters and:are refused. - Browser sessions: a CSRF token on every cookie-authenticated mutation, and
cross-site browser mutations are refused (Sec-Fetch-Site/Origin),
which also covers Basic credentials a browser cached from the feed's login
prompt. On https the session and OIDC flow cookies carry the__Host-
prefix, and a request that carries either cookie twice is not
authenticated by it. An expired or revoked session cookie is cleared. - OIDC login uses the authorization-code flow with PKCE (S256), a nonce and
a flow bound to the browser. Only RSA signatures are accepted, andiss,
aud,expandnbfare checked. Discovery, key-set and token requests
use https and follow no redirects. A realm can require claims
(required_claims, e.g. a tenant id), an email claim becomes a username
only withemail_verified,link_existing_by_usernamenever takes over
an account another credential owns, and a realm's client secret is dropped
when itsissuerorclient_idchanges. Login starts are rate-limited per
client network (auth.oidc_login_starts_per_minute). - Credential forwarding requires https unless the upstream is a loopback
address or the realm setsallow_http. It follows no redirects, ignores
HTTP(S)_PROXY, holds no database connection during the upstream call,
and is bounded per realm (auth.credential_forward_max_concurrent) and by
the realm's timeouts. Setidentity_pathfor upstreams that accept a
secret under any username (GitHub, Azure DevOps or GitLab personal access
tokens): without it, any token holder can sign in as any user. Forge sends
the canonical, lowercased username, so an upstream that compares names
case-sensitively (an htpasswd file, for example) must know its users in
lowercase. Names that Unicode compatibility normalization (NFKC) would
change, or that hold invisible characters, are never forwarded. - The users of a deleted credential-forwarding realm are never adopted by
another realm. Upstream and IdP admin-group verdicts apply in both
directions, and a demotion ends the user's browser sessions. - Users can be deactivated (
PATCH /admin/users/{id}with
{"active": false}): their sessions and cached verdicts are revoked and
their tokens refused. A forced re-authentication
(POST /admin/users/{id}/reauth) ends their sessions and revokes the
tokens they minted from a credential-forwarding session. The last active
administrator cannot be removed, and the last enabled realm people sign in
with interactively cannot be disabled or deleted. - The bootstrap admin is created once and stays deleted after removal. Boot
logs a security warning while it still accepts the placeholder password.
Startup refuses placeholder credentials, including a secret that still
holds a<…>placeholder of the example env files, and warns about a
non-https or loopbackpublic_base_urland about disabled secret
encryption.
Tokens and the admin API
- The admin API accepts a browser session or an admin-scoped token
({"actions": ["admin"]}), never a password sent over Basic, and minting
tokens (/admin/me/tokens) takes a session. An unscoped token of an admin
can push and restore but gets 403 on/admin/*, so a leaked CI token
cannot mint tokens or create admins. An administrator's password still
signs in through/auth/login: give administrators an OIDC realm, and
NuGet clients tokens. - An expired or revoked API token, or one of a deactivated user, sent as the
Basic password is refused and never tried as a password. A token typed
into the web login's password field is refused the same way, live or not. - Admin request bodies refuse unknown fields (422 naming the field), and a
realm'sconfigrefuses unknown keys (400), as do the[oidc]and
[auth.upstream_basic]config sections that seed realms (at startup), so
a typo never falls back to an unsafe default. Token listings show each
token's scope. - Every package change (push, redeploy, unlist, relist) and every admin
change is logged at INFO under theforge::audittarget with the
principal's ids.
Reading and publishing
- RBAC applies on every feed, browse and admin path. A repository name that
does not exist answers like one the caller may not read (401 or 403), so
names cannot be enumerated. - With
anonymous_mode = "all", signed-in users can also read what
anonymous callers can; underoffandallowlistthey hold only their own
grants. Underallowlist, the anonymous grant requires aHostthat
matchespublic_base_urlorserver.allowed_hosts(a DNS-rebinding
guard), so the reverse proxy must preserve theHostheader.
X-Forwarded-Foris honoured only fromauth.trusted_proxy_cidrs, and a
malformed hop leaves the client unknown, never allowlisted. - With
allow_redeployon, replacing an existing version needs the delete
permission as well as write, so a push-only token cannot overwrite a
released version. - Groups guard against dependency confusion:
prefer_hosteddecides over
every hosted member, whatever the caller can read; ids under
reserved_prefixesare never served from a proxy member; and once a group
flags owner members, only owners claim ids. A nested group keeps its own
guards when it is read through an outer group. - Packages are read the way NuGet reads them: exactly one
.nuspecat the
package root, found with percent-decoded entry names and\as a
separator, and one<id>and<version>. Archives with repeated entry
names, Info-ZIP Unicode path fields, a<!DOCTYPE>manifest, inconsistent
ZIP64 records, or a version NuGet clients cannot read are refused, so
Forge and a NuGet client never see different identities for one package. - Feed responses are inert in a browser (
Content-Security-Policy: default-src 'none'; sandbox,Content-Disposition: attachment,
nosniff). Package ids are validated against the NuGet id grammar on
every route and before a proxy builds an upstream URL. Searchqis
capped at 256 bytes and 8 terms.
Proxies, secrets and caches
- A proxy refuses upstream redirects to, and resolutions of, link-local and
cloud-metadata addresses, sends its credentials only to the origin of its
remote_url, drops them whenremote_urlmoves to another origin, and
serves an upstream.nuspeconly if it names the requested package.
Inside the package-delay window it does not download a version at all. - Encrypted secrets are bound to where they are sent: a proxy password to
the origin ofremote_url, an OIDC client secret to the realm's issuer and
client id. Key rotations are serialized across instances, key material is
wiped from memory after use, and an instance without[crypto]refuses to
start on a database that holds encrypted secrets. Config errors and the
Debugoutput of the configuration never contain secrets. - Redis is a trust boundary: authorization sets, credential verdicts, OIDC
discovery documents and signing keys, workload-identity verdicts and a
proxy's upstream metadata are cached in each instance's memory only, and
Redis only relays their invalidations.rediss://(TLS) URLs are
supported. - Trusted publishing: a rule for a custom issuer must pin a stable identity
claim, or one the realm lists inidentity_claims. Unlist, relist and
delete by a workload pass the same checks as a publish and are recorded in
trusted_publish_audit.
HTTP and supply chain
- Responses carry
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,
Content-Security-Policy: frame-ancestors 'none'and
Referrer-Policy: same-origin; an https deployment sends HSTS. The web UI
has a strict Content Security Policy: scripts only from its own origin plus
the hashed inline theme bootstrap, noeval, and fetches only to the same
origin. - API responses (
/admin,/auth,/browse) and feed responses to a
request with credentials areCache-Control: private, no-store, and feed
responses carryVary: Authorization, Cookie, X-NuGet-ApiKey(for
anonymous_mode = "allowlist"see Known limitations). - Slow clients are bounded by a header-read timeout
(server.header_read_timeout_seconds, 150 s), a request-body idle timeout
(body_idle_timeout_seconds, 60 s) and a response timeout
(request_timeout_seconds, 900 s). Connections are HTTP/1 only. - Release images are signed by digest before any tag points at them, and
the verification command pins the exact release workflow. Actions and base
images are pinned by digest, CI audits the Rust and shipped web
dependencies, and Dependabot keeps them current.
Good to know
- Repository names must match
^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$;newis
reserved. In repository updates, an absent field keeps its value and JSON
nullclears it. - The retention worker runs a repository automatically at most once a
minute, whateverdefault_run_interval_secondsand a policy's
min_run_interval_secondssay.
Known limitations
- NuGet is the only package format.
- Trusted publishing uses the direct-token model: the workload token is
accepted until it expires; there is no exchange for a short-lived Forge
token and no single-usejtiledger. - Browser OIDC login validates
iss,aud,exp,nbf, signature and nonce
but notiatorazp. - Rate limiting is limited to a per-instance, in-memory lockout after failed
passwords. There is no limit on/auth/identify, token creation or
workload-token failures. - Secret encryption is optional (plaintext without a
[crypto]section).
Only the env/file root-key providers exist; AWS KMS is not supported yet. - A proxy fetches a new package version once per instance. Several instances
that miss the same version at the same time each download it. - The package delay is dated from the upstream publish time only for the
versions in the newest 10 pages (640 versions on nuget.org) of a
package's upstream registration; older versions, and versions of an
upstream without a registration, count from the proxy's first sighting. - A group's search is capped at 1,000 results per member, so totals and
paging are exact only below that. - A token whose scope lists a group's repository id reads through the group
only from the members it lists as well: reading a group grants no read on
its members. - Retention has no per-run delete cap.
- No metrics endpoint, request IDs or JSON log output. Shutdown drains
in-flight requests without a deadline. - The web UI requires sign-in, even for repositories that allow anonymous
reads; NuGet clients can still read those anonymously. - There is no web console yet for RBAC roles and grants, retention, user
deactivation or key rotation; use the/adminAPI. - CI does not scan the container image for OS-package vulnerabilities.
- A proxy trusts its upstream: it follows the resource
@ids and redirects
the upstream names, also to private or plain-http addresses (credentials
go only to the upstream's own origin), and parses its metadata documents
(up toproxy.max_metadata_bytes, 32 MiB) in memory. Point proxies only
at upstreams you trust. Behind an outbound proxy (HTTP(S)_PROXY) the
proxy resolves host names, so Forge refuses only literal cloud-metadata
addresses and the well-known metadata host names there. - Unless a group flags owner members, it trusts all its hosted members
equally: anyone who can push to any hosted member can publish under the
group'sreserved_prefixes, and withprefer_hostedcan claim any
public package id for the whole group. Flag the trusted members as owners
when a group mixes feeds with different writers. Owners claim only the
ids they have: an id no owner has is still served by every member, so
reserve the internal prefixes as well. - With
prefer_hosted, a caller who cannot read a group's hosted members
still learns whether one of them has a package id the upstream also has:
they get 404 instead of the public package. - A role grant of the
adminaction on one repository has no effect: every
/adminoperation needs a global administrator. - An OIDC realm that provisions accounts can take a username before a
credential-forwarding user of that name first signs in, where users choose
their own username claim at the IdP. That user is then refused until an
administrator removes the other account. - Under
anonymous_mode = "allowlist", anonymous feed responses carry no
Cache-Control: no-store, and whether one is served depends on the
client's address, which a cache does not key on. A shared cache in front
of Forge that stores responses without caching headers (Varnish's default
does) can serve a package an allowlisted client fetched to a client
outside the allowlist. Do not let such a cache store/v3responses. - While Redis is unreachable, an instance hears no invalidations from the
others and converges by TTL (60 s for authorization). Anything that can
publish to Redis can grow every instance's memory: the invalidation
bookkeeping is not bounded. - Every
.nupkgdownload updates its version's download counters in
PostgreSQL, unthrottled. Hundreds of downloads of one version per second
queue on those rows and can hold every database connection. - Forge does not check the owner or permissions of
server.staging_dir. On
a host shared with other users, use a directory only Forge's user can
write (the container image and the Kubernetes manifests are not affected). - The Helm chart rolls the pods on a change of
secretEnv(and of the
in-cluster Redisauth) through an unsalted SHA-256 of the values in a pod
annotation: whoever can read the Deployment can test guesses of a weak
secret against it. - The release build pulls the Dockerfile frontend, BuildKit and the SBOM
scanner images by tag, not by digest. - Login lockouts and failure counts are per instance and lost on restart
(the networks a username signed in from are kept in the database). - A first download through a proxy runs inside the request, so
server.request_timeout_seconds(900) caps it below
proxy.download_timeout_seconds(1800); NuGet clients give up far
earlier anyway.
Image
docker pull ghcr.io/schmitch/forge:1.0.0
# digest: sha256:ed99ce037e3f373db741b4d853662466b379ff037bcb529d94e6eca750cb24f9
cosign verify ghcr.io/schmitch/forge@sha256:ed99ce037e3f373db741b4d853662466b379ff037bcb529d94e6eca750cb24f9 \
--certificate-identity https://github.com/schmitch/forge/.github/workflows/release.yml@refs/tags/v1.0.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Helm chart
Deploys exactly the image above (by digest).
helm install forge oci://ghcr.io/schmitch/charts/forge --version 1.0.0
# digest: sha256:a034be902a341c6d2df31d099706048d4b9acea6ed9889f797c7f201c726571c
cosign verify ghcr.io/schmitch/charts/forge@sha256:a034be902a341c6d2df31d099706048d4b9acea6ed9889f797c7f201c726571c \
--certificate-identity https://github.com/schmitch/forge/.github/workflows/release.yml@refs/tags/v1.0.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com