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.
settings.xml, the server's <configuration><httpHeaders> block with
a Jenesis-Repository-Key property.
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.
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. |