Authentication & access

A fresh Jenesis Repository enforces authentication: every request is checked against a per-credential key, and a request that carries none is refused. Before you expose a server to anyone else, you choose how it identifies callers. There are three deployment shapes: a key-gated server, which starts from a bootstrap key; an open server on a trusted network; and a public read-only mirror. The chapter then covers the keys themselves - their grants, and how to issue and revoke them. Signing in to the web console is a separate matter, covered at the end.

Three ways to deploy

Key-gated, starting from a bootstrap key

A real deployment keeps enforcement on and issues each client a key of its own. The first key is the one problem: every route that can mint a key requires one already. jenreg.bootstrap-key solves that - a key you choose, which the server provisions at boot with every right on every repository of its tenant, and which you then use to issue the keys you want.

A key is a self-describing string, jenk_<tenant>.<secret><checksum>, and the bootstrap key has to be well-formed because the server reads the tenant out of it. The clone mints one; the checksum is the CRC32 of everything before it, base64url-encoded without padding, so a leaked key is recognisable offline:

java -Djenesis.execute.module=source+server-spi build/jenesis/Execute.java        # a key for the tenant "default"
java -Djenesis.execute.module=source+server-spi build/jenesis/Execute.java acme   # a key for the tenant "acme"

It prints the key and nothing else.

Start the server with it, once:

JENREG_BOOTSTRAP_KEY=jenk_default.… JENREG_FILESYSTEM_ROOT=/var/lib/jenesis-repository \
  java -Djenesis.execute.module=source+bundle build/jenesis/Execute.java

A malformed value refuses to boot rather than being ignored, and the server logs a SECURITY line for as long as the setting is present: the bootstrap key never expires, is re-provisioned on every boot, and grants everything. Use it to issue real credentials (below), then unset it and restart - its entry stays in the store until you revoke it like any other key.

Open, on a trusted network

For a laptop, a CI network, or any deployment that lives behind its own perimeter, you can skip keys altogether:

JENREG_AUTH=false

Every request is then served anonymously: anyone who can reach the port can read, publish and delete. The choice is never silent. The server logs a warning at boot and raises the jenreg.auth.open advisory, which GET /api/posture reports and the console shows on its Security posture panel.

A public read-only mirror

For a repository that anyone may read but nobody may change - an open-source project's artifacts, a browsable archive - keep enforcement on, grant keyless callers read access, and remove writing altogether:

JENREG_AUTH=true                             # the default
JENREG_ANONYMOUS_RIGHTS=repository:read
JENREG_READ_ONLY=true

jenreg.anonymous-rights names exactly what a caller without a key may do. It is blank by default, so an enforcing server stays closed until you say otherwise. The value is a comma-separated list in the grant grammar below: repository:read on every repository, <repository>=repository:read for one named repository, <surface>:* for every verb on a surface, or * for everything. Granting writes anonymously is allowed and warned about at boot.

jenreg.read-only refuses every write with 403 - a publish, an import, a proxy fetch caching an artifact - at the store itself, so no credential and no internal path gets around it. A common pattern pairs one firewalled read-write instance that publishes into a shared bucket with public read-only instances that serve from it.

Keys, grants and roles

With enforcement on, a client identifies itself with a key. A GET or HEAD needs the repository:read right on the requested path; any other method needs repository:write. A request without a key is answered 401; one whose key lacks the right is answered 403. The repository a request is authorised against is the one its Jenesis-Repository-Name header names, never the one the route serves; without the header the request is authorised against the deployment-wide * scope, which only a key holding a wildcard grant satisfies.

The key travels in whichever header the client can send:

Header Who sends it that way
Jenesis-Repository-Key: jenk_… The server's own header - curl, scripts, the console panels, Maven's <httpHeaders>. It wins when present.
Authorization: Bearer jenk_… (or the bare key) A Jenesis build, whose jenesis.maven.token / jenesis.module.token go out as the Authorization header; Gradle's HttpHeaderCredentials; any bearer-token client.
Authorization: Basic … with the key as the password docker login -u anyone -p jenk_… - the user name is ignored.

Only a well-formed key is ever read out of Authorization; anything else in that header is treated as no key at all, never as a credential. A request for an artifact that presents no key is answered 401 with a WWW-Authenticate: Basic challenge, which is what Maven (on a read) and a Docker client wait for before sending the credential they hold; the /api/ paths answer a bare 401, so a browser is never shown a sign-in dialog.

curl -H "Jenesis-Repository-Key: jenk_default.…" \
     -T app-1.0.jar http://repo.example.com/repository/maven/com/example/app/1.0/app-1.0.jar

java -Djenesis.maven.uri=https://repo.example.com/repository/maven/ \
     -Djenesis.maven.token=jenk_default.… build/jenesis/Make.java

The jenk_ prefix and the trailing checksum let a secret scanner recognise a leaked key and let the server reject a malformed one without a store lookup. The tenant travels in the key, so a request is attributed without a directory read, and only the key's SHA-256 hash is ever stored - a lost key is re-issued, never recovered.

A key's rights are a map from scope to rights. A scope is a repository name (* for every repository), optionally narrowed to a path prefix as <repository>:<prefix>, which covers a request only when its path lies at or under the prefix on a segment boundary. A right is a <surface>:<verb> token over the surfaces repository, cache and manage, each with read and write; <surface>:* grants both verbs and a bare * grants everything. Three built-in roles bundle them:

Role Grants
read-only cache:read, repository:read
deploy the above plus cache:write, repository:write
admin *

A key expires 90 days after it is created unless given another lifetime (the deployment can set a different default and a ceiling - see the settings below), can be restricted to a source-address allowlist, and is checked against its stored grants on every request. A narrowed or revoked key stops working at once on the node that made the change, and on every other node within jenreg.cache.ttl - five minutes by default, 0 to switch the cache off - which is how long a node serves a credential it has already read before asking the store again.

Maven itself has no bearer-token setting, so a Maven client presents the server's own header: in settings.xml, the server's <configuration><httpHeaders> block with a Jenesis-Repository-Key property.
## Issuing and revoking keys

Keys are administered through /api/credentials. Issuing a key is administration, not publishing, so the routes require the manage:read right (to list) or manage:write (to change) at deployment scope - the bootstrap key and any key with * carry them; a deploy key cannot issue more keys.

Mint a key, optionally with a label and a lifetime. The secret comes back once:

curl -H "Jenesis-Repository-Key: $BOOTSTRAP" -H 'Content-Type: application/json' \
     -d '{"label":"ci-publisher"}' http://localhost:8080/api/credentials
{"id":"deead028…","key":"jenk_default.PLy9vc…","expires":"2026-11-19T16:31:23Z"}

A new key has no rights until you grant some. Grant a scope and the tokens it gets - * for every repository, or one repository's name:

curl -H "Jenesis-Repository-Key: $BOOTSTRAP" -H 'Content-Type: application/json' \
     -d '{"scope":"*","tokens":["repository:read","repository:write"]}' \
     http://localhost:8080/api/credentials/deead028…/grants

The whole surface, where <id> is the 64-character hash the mint returned and the listing shows:

Request Effect
GET /api/credentials The tenant's credentials - id, label, created, expires, allowed addresses, grants, and, while jenreg.track-key-usage is on, when each was last used, from which address, and how many times - never a secret. One page per request: at most limit (500, the default and the maximum) in id order from after; when more remain, the X-Next-Cursor response header carries the after value of the next page.
POST /api/credentials Mint. Body: label, expires (P30D from now, or an instant; blank = the 90-day default), nonExpiring: true. Answers 201 with id, key, expires.
POST /api/credentials/<id>/grants Set the rights at one scope. Body: scope, tokens (a list) and an optional expires - see Grants that lapse below.
DELETE /api/credentials/<id>/grants/<scope> Remove the rights at one scope.
PUT /api/credentials/<id>/expiry Change the expiry. Body: expires as above; blank clears it.
PUT /api/credentials/<id>/allowed-ips Restrict the key to source addresses. Body: addresses, comma-separated CIDRs or addresses; blank clears it.
POST /api/credentials/<id>/rotate Mint a successor that inherits the grants and allowlist, and expire the old key after an overlap. Body: overlap (default seven days). Answers 201 like a mint.
DELETE /api/credentials/<id> Revoke. The key stops working at once.

The console's Credentials panel does the everyday part of this - list, issue with a label, revoke - with a managing key pasted into the page; grants and rotation are API calls.

Groups and people

A key is not the only holder of rights. The same grants - the same scopes, the same <surface>:<verb> tokens, the same three roles - are held by other kinds of subject, and each is administered through a surface of the same shape:

Holder Named by Surface
A key the 64-character id a mint returns /api/credentials
A person the sign-in mechanism's own id - github/<id>, oidc/<sub> /api/principals
A group a name you choose, or one an identity provider pushes /api/groups
A caller with no key nothing; it is the request itself jenreg.anonymous-rights

People and groups are what console sign-in resolves to. Signing in establishes who someone is; what they may see is decided from the rights that id holds, directly or through a group they are in - which is why the no-access screen shows the signed-in person their own id. That id is exactly what these routes take.

Both surfaces sit under /api/, so both need manage:read to read and manage:write to change, like the credentials one, and both page the same way: after and limit, with an X-Next-Cursor response header while more remain.

A person's id is never a path segment. It carries a slash, so a write takes it in the body and a delete takes it as a query parameter, rather than obliging every client to agree on how to encode it.

Request Effect
GET /api/principals The tenant's people - id, label, and the rights granted to them directly. What someone holds through a group belongs to the group and is listed there.
POST /api/principals/grants Grant rights at one scope. Body: id, scope, tokens, optional expires.
DELETE /api/principals/grants?id=<id>&scope=<scope> Remove the rights at one scope.
DELETE /api/principals?id=<id> Remove the person: every grant made to them directly, and their metadata. Their group memberships are untouched - those belong to the groups.
Request Effect
GET /api/groups The tenant's groups - name, label, and the rights each grants per scope.
GET /api/groups/<name>/members One group's members, one page per request.
POST /api/groups/<name>/grants Set the group's rights at one scope. Body: scope, tokens, optional expires.
DELETE /api/groups/<name>/grants/<scope> Remove the group's rights at one scope.
POST /api/groups/<name>/members Put a person in the group. Body: id. The group need not be created first.
DELETE /api/groups/<name>/members?id=<id> Take a person out of the group.
DELETE /api/groups/<name> Delete the group: its grants, its metadata and its membership.
curl -H "Jenesis-Repository-Key: $ADMIN" -H 'Content-Type: application/json' \
     -d '{"scope":"*","tokens":["repository:read"]}' \
     http://localhost:8080/api/groups/engineering/grants

curl -H "Jenesis-Repository-Key: $ADMIN" -H 'Content-Type: application/json' \
     -d '{"id":"oidc/8f3c1a…"}' \
     http://localhost:8080/api/groups/engineering/members

A group's rights reach its members because the write re-derives them before it returns: the next request that member makes already sees the change, so there is nothing to wait out and nothing to re-run. The same holds in reverse - removing a member, or deleting the group, re-derives everyone it touched, so nothing of it is left conferring rights.

A group with members and no grants confers nothing, which is why the first POST of a member creates it. There is no state here in which a decision nobody has made yet reads as access.

Grants that lapse

Any grant, on any holder, may be given an expiry: expires in the body of the write, either an ISO-8601 duration from now (P30D, PT12H) or an absolute instant (2026-12-01T00:00:00Z). Blank or absent is a grant that does not lapse - which is still the usual case, and the default.

It is how a temporary right is given without anyone having to remember to take it back: a contractor's read access until the end of the month, a manage:write for the length of a migration. When it lapses the holder remains and their other grants stand; only that one scope stops being held. An expiry that cannot be read back counts as expired rather than as absent, so a damaged record narrows access rather than widening it.

What a client can find out

GET /api/capabilities answers a small JSON map a client or console reads to adapt itself: auth (whether the wire is credential-gated), readOnly, and anonymousRights (the keyless grant, if any). GET /api/posture lists the security advisories the current configuration raises, from jenreg.auth.open to jenreg.ratelimit.unset, each with the setting that would clear it. Both are reads: on an enforcing server they need a key with read rights, or an anonymous read grant, and the posture report is scoped to the whole deployment rather than to one repository.

Signing in to the console

The web console runs in the server's process but has its own sign-in. People authenticate through an identity provider, not with repository keys.

Signing in and holding access are two separate decisions. Sign-in succeeds for anyone your identity provider authenticates: the provider owns who may authenticate - app assignment in Entra or Okta, an OAuth app scoped to one organisation - and refusing again here would duplicate that control while doing it worse, since the console only ever sees an identity the provider has already decided about. What a signed-in person may see is decided separately, from the rights they hold. Someone who holds nothing gets a screen that says so and shows the id an administrator needs to grant to, rather than an error: that id is an opaque provider subject, and it is otherwise unobtainable until its owner has signed in once.

Two providers are supported and either or both may be configured; with neither, sign-in is disabled and the console shows a notice instead of failing:

# GitHub OAuth app
JENREG_UI_GITHUB_CLIENT_ID=…
JENREG_UI_GITHUB_CLIENT_SECRET=…

# One OpenID Connect issuer (Google, Keycloak, Okta, Entra ID, Auth0, …); endpoints are discovered
JENREG_UI_OIDC_ISSUER_URI=https://login.example.com/realms/main
JENREG_UI_OIDC_CLIENT_ID=…
JENREG_UI_OIDC_CLIENT_SECRET=…
JENREG_UI_OIDC_NAME="Company SSO"          # labels the sign-in button

Administrators are named by provider-qualified id in JENREG_UI_ADMINS, comma-separated: github/<id> for a GitHub user, oidc/<sub> for an OIDC subject. The list is empty by default, so an unconfigured console grants administration to nobody.

The setting seeds those grants; it is not the record of them. Each id it names is granted deployment-wide administration on every start, exactly as jenreg.bootstrap-key is re-provisioned for as long as it is set, and every later question is answered from the grant rather than from the setting. Two consequences follow, and both are the price of a seed rather than a mirror:

  • Removing an id from the list does not remove that person's administration. The grant stands until it is revoked through the API. A seed that reconciled would silently undo every grant made through the console, which is the surface operators are told to use.
  • An administrator granted through the API is a real administrator, listed and revocable, whether or not the setting ever mentioned them.

A * entry is refused at startup - the server does not begin. It used to mean "every signed-in user is an administrator"; an administrator is a holder of rights, and a wildcard names no holder, so there was nothing to read back, revoke, or show in a list of who administers the deployment. Refused rather than ignored, because ignoring fails in both directions at once: the operator believes they granted something, and in fact nobody holds it. If you want everyone your provider authenticates to hold some right, grant it to a group and put them in it.

The session cookie is sent only over HTTPS. For a local run over plain http, where the cookie has to survive the OAuth redirect without TLS, set JENREG_UI_SECURE_COOKIE=false.

For local work, start the server with SPRING_PROFILES_ACTIVE=dev. The profile adds a form login at /login/dev with two built-in accounts, admin/admin and viewer/viewer, so both roles can be tried without an identity provider. It raises the jenreg.profile.dev advisory, and the server refuses to start under it on anything but the loopback address.

Settings

Server-side settings, read at startup from the environment, a -D system property or allinone.properties:

Key Default Effect
jenreg.auth true Enforce the key credential. false serves every request anonymously and raises jenreg.auth.open.
jenreg.bootstrap-key (blank) A well-formed key provisioned at boot with * on every repository of its tenant; logged as a SECURITY line while set. Unset it once real keys exist.
jenreg.credential-default-lifetime (blank) The lifetime of a key minted without an explicit expiry, as an ISO-8601 duration; blank is 90 days.
jenreg.credential-max-lifetime (blank) The longest any key may live; a longer request is pulled back to it. Blank leaves lifetimes uncapped.
jenreg.anonymous-rights (blank) Rights granted to a caller without a key, in the grant grammar; only meaningful with jenreg.auth=true.
jenreg.read-only false Refuse every write, external or internal, with 403. Advertised at GET /api/capabilities.
jenreg.tenant / jenreg.repository default The names of the one artifact space this deployment serves; a key's tenant must match.

Console settings:

Key Default Effect
jenreg.console true Serve the console in this process; false leaves only the repository's own endpoints.
jenreg.ui.admins (blank) Comma-separated github/<id> / oidc/<sub> ids seeded as deployment administrators on every boot. Not a mirror: dropping an id does not revoke it. A * entry is refused at startup.
jenreg.ui.github.client-id / .client-secret (blank - disabled) GitHub OAuth app credentials.
jenreg.ui.oidc.issuer-uri / .client-id / .client-secret (blank - disabled) The OIDC issuer and client.
jenreg.ui.oidc.name Single sign-on The label on the OIDC sign-in button.
JENREG_UI_SECURE_COOKIE true Send the session cookie over HTTPS only.