Skip to content

v1.0.0

Choose a tag to compare

@github-actions github-actions released this 21 Oct 12:17
· 806 commits to main since this release
Immutable release. Only release title and notes can be modified.
2be2082

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, /readyz and /version, graceful SIGTERM,
    supervised background workers, an audit log of every package change and
    admin action (the forge::audit log 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 carries LICENSE and 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 as config (rendered to forge.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 and extraObjects; 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, and iss,
    aud, exp and nbf are 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 with email_verified, link_existing_by_username never takes over
    an account another credential owns, and a realm's client secret is dropped
    when its issuer or client_id changes. 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 sets allow_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. Set identity_path for 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 loopback public_base_url and 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's config refuses 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 the forge::audit target 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; under off and allowlist they hold only their own
    grants. Under allowlist, the anonymous grant requires a Host that
    matches public_base_url or server.allowed_hosts (a DNS-rebinding
    guard), so the reverse proxy must preserve the Host header.
    X-Forwarded-For is honoured only from auth.trusted_proxy_cidrs, and a
    malformed hop leaves the client unknown, never allowlisted.
  • With allow_redeploy on, 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_hosted decides over
    every hosted member, whatever the caller can read; ids under
    reserved_prefixes are 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 .nuspec at 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. Search q is
    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 when remote_url moves to another origin, and
    serves an upstream .nuspec only 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 of remote_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
    Debug output 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 in identity_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, no eval, and fetches only to the same
    origin.
  • API responses (/admin, /auth, /browse) and feed responses to a
    request with credentials are Cache-Control: private, no-store, and feed
    responses carry Vary: 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}$; new is
    reserved. In repository updates, an absent field keeps its value and JSON
    null clears it.
  • The retention worker runs a repository automatically at most once a
    minute, whatever default_run_interval_seconds and a policy's
    min_run_interval_seconds say.

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-use jti ledger.
  • Browser OIDC login validates iss, aud, exp, nbf, signature and nonce
    but not iat or azp.
  • 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 /admin API.
  • 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 to proxy.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's reserved_prefixes, and with prefer_hosted can 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 admin action on one repository has no effect: every
    /admin operation 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 /v3 responses.
  • 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 .nupkg download 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 Redis auth) 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