Authentication and access control
peryx uses one access model across ecosystems. It decides whether a principal may take an action on a resource in an index. Ecosystem owners map client credentials, resource names, and routes onto that model.
For task recipes see control access to an index; for the reasoning behind the design see client auth versus upstream credentials.
The model
An access decision has four inputs.
A principal is who a request speaks as after peryx checks its credential. It is either anonymous or a named subject, the name of the token that authenticated it. A credential that matches no token leaves the request anonymous, so an invalid token is exactly as privileged as no token at all.
An action is one of read, write, and delete. The ecosystem owner maps protocol operations onto these actions.
A grant pairs a set of actions with a set of resource globs. A token carries one grant, and a grant lets its actions reach any resource one of its globs matches.
An index ACL declares whether the index permits anonymous reads and which tokens it accepts. Each index has one, so a cached index, a hosted store, and a virtual index all answer the same question.
Where the model is enforced
Core resolves the index ACL and grant before an implementation performs a protected action. The implementation defines its credential exchange, canonical resource identity, protected routes, and error response. Read coverage can differ by implementation and route.
Supported access implementations:
GET /+status classifies version, coarse health, and the basic index list as public. Counters and implementation
rollups require operator:read; upstream hosts, access-token state, and recent writes require administration:read.
GET /+stats requires operator:read because it names repositories and resources. Discovery endpoints and aggregate
GET /metrics remain public. Restrict /metrics at the reverse proxy under the Prometheus security model. OpenID
Connect can add browser sign-in and a read-only web session. LDAP resolves server users for login consumers but does not
add an HTTP login route.
Server roles and protected responses
Server users hold fixed roles over the whole server or one repository. A decision matches one named scope and one resource against those grants. Repository reach cannot cover operator data, even when the role itself carries a server scope. Separate role and resource checks follow the action-and-scope model used by Grafana RBAC.
| Fixed role | repository:read | repository:write | repository:delete | operator:read | analytics:read | administration:read |
|---|---|---|---|---|---|---|
| Administrator | yes | yes | yes | yes | yes | yes |
| Repository publisher | yes | yes | yes | |||
| Repository reader | yes | |||||
| Operator | yes | yes |
| Field classification | Public caller | Repository caller | Operator caller | Administrator caller |
|---|---|---|---|---|
| Public | yes | yes | yes | yes |
| Repository | yes | yes | ||
| Operator | yes | yes | ||
| Administrator | yes |
operator:read covers runtime health, queues, and configuration state. analytics:read covers retained usage
aggregates. The two scopes remain distinct, so each handler states which data family it reads. administration:read
covers server users, grants, credential state, and other data whose disclosure changes the attack surface. The
Prometheus security model treats operational and debug endpoints as
trusted-user data.
API and UI handlers classify each field as public, repository, operator, or administrator data, then filter a bounded model before serialization. Repository and operator data remain separate; an administrator receives all four levels. A nested object inherits the highest classification of its contents unless the handler filters that object first.
Role and field primitives do not authorize a route by themselves. Migrate a route in this order:
- Resolve the server user and call
authorize_scopedwith the route's exact scope and resource before reading protected data. - Build a bounded response without holding metadata or request-path locks, and assign every field a classification.
- Pass the scoped authorization result and classified fields to the shared response filter. The checked scope sets the caller's maximum field class; handlers cannot promote an operator decision to administrator access. Return the filter's generic denial without adding a resource path or query value.
- Apply
private, no-cacheto caller-specific responses orno-storewhen a response contains credential or sensitive administration state.
Authorization failure returns no partial response. Peryx records the user, required scope, and bounded reason for a denial; it omits the protected resource and raw query string.
Reverse proxies must preserve Peryx's Cache-Control header. The
RFC 9111 private directive prevents a shared cache from
storing a caller-specific response while allowing a private cache to retain it; no-cache requires validation before
reuse. The no-store directive applies to private and
shared caches. These directives constrain conforming caches and do not replace route authorization or TLS.
Resource globs
A grant's resources contains patterns that peryx matches against the implementation's canonical resource name. *
stands for any run of characters, including /; other characters match themselves.
| Pattern | Matches | Does not match |
|---|---|---|
* | every resource in the index | |
team-* | team-widgets, team-tools | other-widgets |
team/* | team/api, team/api/edge | team, teamwork/api |
acme-internal | acme-internal only | acme-public |
Because * crosses /, team/* covers a resource subtree at any nesting depth.
Matching is byte-for-byte against the ecosystem's normalized resource name, not the spelling the URL carried. Write PyPI
grants as PEP 503 normalized project names: flask covers the Flask, FLASK and flask spellings of that project,
whereas a grant spelled Flask covers none of them. Supported identity rules:
[auth]
The [auth] table holds the settings every index's access rules share. All keys are optional.
| Key | Meaning | Default |
|---|---|---|
signing_key | Secret peryx signs its own tokens with | (none) |
signing_key_file | Path to read signing_key from instead of inlining it | (none) |
token_ttl_secs | Lifetime from 1 through 86400 seconds; OCI token realm minimum is 60 | 300 |
default_anonymous_read | What an index's anonymous_read defaults to when the index omits it | true |
signing_key and token_ttl_secs configure token-minting implementations. peryx reads the key at startup and uses it
to sign scoped tokens. Peryx requires 60 through 86400 seconds when a signing key and OCI index enable the OCI token
realm. A deployment with no OCI index accepts 1 through 86400. Set at most one of signing_key and signing_key_file.
Signing key
The resolved signing key must contain at least 32 bytes. This meets the
HS256 minimum in RFC 7518, but length alone does not provide
entropy. Generate the key with a CSPRNG instead of choosing a long phrase. For example,
openssl rand can encode 32 random bytes as 64 hexadecimal
characters:
$ umask 077
$ openssl rand -hex 32 > peryx-signing-key
Nodes that mint or verify realm tokens or browser state must use the same value. To rotate it, drain those nodes, replace the key on each one, restart them, and restore traffic after the final node has the new value. Draining traffic prevents a mixed-key serving window. Rotation invalidates outstanding realm JWTs, browser sessions, and pre-authentication cookies. Do not retain an undersized key as a fallback.
LDAP providers
Each [[auth.ldap_provider]] names one StartTLS directory. Peryx constructs these providers at startup without opening
a connection; the first login performs the connection, TLS upgrade, search, and bind. peryx accepts only ldap:// URLs
because each connection upgrades with StartTLS. A custom CA file extends the platform trust roots.
[[auth.ldap_provider]]
id = "corporate"
url = "ldap://directory.example:389"
base_dn = "ou=people,dc=example,dc=com"
mode = "service-search"
username_attribute = "uid"
bind_dn = "cn=peryx,ou=services,dc=example,dc=com"
bind_password_file = "/run/secrets/peryx-ldap-password"
subject_attribute = "entryUUID"
display_name_attribute = "displayName"
group_attribute = "memberOf"
ca_file = "/etc/peryx/directory-ca.pem"
connect_timeout_secs = 3
request_timeout_secs = 5
max_connections = 8
[[auth.ldap_provider.group_mapping]]
group = "cn=artifact-readers,ou=groups,dc=example,dc=com"
role = "repository_reader"
repository = "private"
service-search binds the configured service account, searches below base_dn for one exact username_attribute, then
binds that entry with the presented password. Set exactly one of bind_password, bind_password_file, or
bind_password_env. direct-bind needs dn_attribute instead of the service-account fields; it constructs
{dn_attribute}=<escaped username>,{base_dn} and binds that DN.
subject_attribute must be stable across renames. OpenLDAP's entryUUID and Active Directory's objectGUID fit;
email, username, and display name do not. display_name_attribute supplies the initial local name. group_attribute is
optional. When present, its exact values select group_mapping entries. A mapping without repository grants its role
at server scope; a mapping with repository must name a configured index.
peryx escapes LDAP filters and DN components before use. Searches return at most one entry and request the configured
attributes. max_connections is the total socket bound for the provider. peryx discards a socket that has carried a
user bind instead of returning it to the pool, including when cancellation or a timeout interrupts the login. Failed
credentials return no identity and cannot update the local user or managed grants. Directory and timeout failures remain
distinct errors without exposing the username, password, subject, groups, or CA contents.
The provider service returns a stable local user ID after the provider-subject link commits. It does not mint a token, create a session, accept HTTP Basic credentials, or change a artifact route; those are separate consumers of the login service.
OIDC login providers
Each [[auth.oidc_provider]] configures one OpenID Connect issuer for browser sign-in through the Authorization Code
flow with PKCE. Peryx builds the provider at startup without a network call; the first login fetches and caches the
issuer's discovery document and signing keys and pins the configured issuer. The browser session cookie uses a key
derived from [auth].signing_key; startup rejects an [[auth.oidc_provider]] without a configured signing_key.
[[auth.oidc_provider]]
id = "corporate"
issuer = "https://idp.example/realms/main"
client_id = "peryx"
client_secret_file = "/run/secrets/peryx-oidc-secret"
redirect_uri = "https://artifacts.example/_/login/corporate/callback"
scopes = ["openid", "email", "groups"]
subject_claim = "sub"
display_name_claim = "name"
groups_claim = "groups"
clock_skew_secs = 30
request_timeout_secs = 8
[[auth.oidc_provider.group_mapping]]
group = "service-admins"
role = "administrator"
[[auth.oidc_provider.group_mapping]]
group = "packagers"
role = "repository_reader"
repository = "artifacts"
issuer and redirect_uri must be https and carry no fragment, and the issuer must carry no query. Register
redirect_uri verbatim with the provider; it is the /_/login/{id}/callback route peryx serves. Omit client_secret
(and its _file and _env siblings) for a public client that relies on PKCE alone, or set exactly one of them for a
confidential client. scopes always includes openid. subject_claim names the stable, opaque claim that identifies
the user; an email or a display name, which can be reassigned, does not belong here. display_name_claim supplies the
initial local name. groups_claim is optional; when present, its values select group_mapping entries the way LDAP
groups do. A mapping without repository grants a server-scoped role, while one with repository must name a
configured index.
The login flow. GET /_/login/{id} mints state, a nonce, and a PKCE verifier. It seals them into a single-use,
short-lived cookie and redirects the browser to the provider. The provider returns to /_/login/{id}/callback, where
peryx re-opens the sealed handoff and validates the response. The state must match, and the ID token must carry the
pinned issuer, this client's audience, a matching nonce, and a signature from the issuer's current keys, all within
clock_skew_secs. Any mismatch fails the login. A bounded metadata refresh picks up a rotated signing key.
The session. A completed login seals the resolved user into a short-lived peryx_session cookie with HttpOnly,
Secure, and SameSite=Lax, then redirects to the dashboard. The session authenticates the read-only web UI. A
state-changing request still authenticates with an Authorization header token. peryx does not accept the session
cookie as authorization for a mutation, which prevents a CSRF surface. GET /_/session reports the signed-in user and
configured providers for the login page. POST /_/logout clears the cookie.
What a session authorizes. A protected read - a server-rendered page, the UI JSON endpoint behind it, and the status
classification - accepts the session cookie only when the request carries no Authorization header. A header the
request does carry decides that request on its own: a rejected credential is terminal and never falls back to the
cookie. Each session-authorized read loads the account and its role grants from metadata, so the cookie contributes an
identity and never an authority snapshot. A disabled account, a deleted account, and a revoked grant all take effect on
the next request rather than at cookie expiry, and metadata that cannot be read denies rather than serves the sealed
copy.
Outages. request_timeout_secs bounds discovery, key, token, and user-info requests. Once a session exists, no
request reaches the provider. A provider outage fails an in-progress browser login with a retryable 503 but leaves
API-token authentication available, so protected operations continue while the identity provider is down. A
metadata-fetch failure retains cached keys and signature validation unless the provider's Cache-Control withheld them.
Peryx caches no no-store or private response, and under no-cache or must-revalidate a failed refetch fails the
login instead of reusing a document the provider marked stale.
default_anonymous_read = false makes each index ACL deny anonymous reads by default. An implementation applies that
default to its protected routes. Public core routes stay open. An index that should stay open sets
anonymous_read = true.
Per-index keys
These keys sit in an [[index]] table and are also listed under configuration.
| Key | Role | Meaning | Default |
|---|---|---|---|
anonymous_read | all | Whether a request with no credential may read this index | [auth].default_anonymous_read |
A hosted index accepts writes through [[index.access_token]] grants that permit the write action.
[[index.access_token]]
Each [[index.access_token]] table adds one named credential the index accepts. Put these under the hosted index that
stores the writes.
[[index]]
name = "hosted"
hosted = true
[[index.access_token]]
name = "ci"
secret = "ci-secret"
resources = ["team-*"]
actions = ["write", "delete"]
expires_at = "2027-01-01T00:00:00Z"| Key | Meaning | Default |
|---|---|---|
name | Subject a request authenticating with this token speaks as; unique per index | (required) |
secret | Shared secret an ecosystem authentication adapter verifies | (required) |
secret_file | Path to read secret from instead of inlining it | (none) |
resources | Resource globs the token may act on | ["*"] |
actions | Any of read, write, delete; at least one | (required) |
expires_at | RFC 3339 time after which it stops working | never |
A token needs exactly one of secret and secret_file. Once expires_at passes, the token authenticates nothing: a
request presenting it becomes anonymous, exactly as if the password were wrong.
Secret files
Each secret key (signing_key, an access token's secret, and an LDAP bind_password) has a _file sibling naming a
path to read the value from, so no plaintext lives in the config file. peryx reads each file once at startup and trims
surrounding whitespace; an empty file is a startup error. The rationale and the tools it composes with are in
client auth versus upstream credentials.
Server-user records
The metadata store can hold server users for management and later authentication features. A user receives a random, opaque ID at creation. Renaming changes its display name and canonical lookup key without changing that ID. This follows the NIST subscriber-account model, in which mutable account attributes do not replace the stable subject identifier.
peryx trims display names for storage. Lookups compare an NFC-normalized lowercase key, so case changes and equivalent composed Unicode spellings identify the same user. Creating or renaming to an existing canonical name fails without changing either account. The original display spelling remains available for presentation.
New users are active. A disabled user remains inspectable by ID, but the next identity lookup no longer resolves it.
Reactivation restores lookup. Create, rename, disable, and reactivate operations append actor-neutral lifecycle records
in the same transaction as the account change. No operation in this lifecycle stores a password, token, role, or
external identity subject.
Opening an existing metadata store creates the user tables in one metadata transaction. Existing index configuration, cached artifact records, and access policy remain in their current tables. If table initialization fails, the transaction does not leave a partial user schema, and the prior metadata remains available to the existing recovery procedure.
Server users do not yet authorize artifact requests: mapping an authenticated user to grants waits on the role model.
Existing [[index.access_token]] credentials keep their current subjects and behavior when a server user is renamed or
disabled.
External identity links
An external identity is the exact pair of a configured provider ID and that provider's opaque subject. Peryx preserves
the subject's spelling and case instead of substituting a mutable email address or display name. OpenID Connect defines
the iss and sub pair as the stable
identifier; SCIM scopes each external identifier to its provisioning domain. Peryx creates distinct local users for
equal subjects from two providers.
The first successful provider login creates the local user and provider-subject link in the same metadata transaction. Later logins resolve the same stable user ID. A colliding display name does not link accounts; peryx gives the new user a distinct local display name. Linking by a matching email or display name would let one provider impersonate an account created through another trust boundary.
Configured external groups map to the fixed server roles above. Peryx replaces the grants owned by that identity link after each successful provider login, so removing a group takes effect on the next authorization decision. Peryx preserves manual grants and grants owned by another link. The linker receives verified identities, so a failed provider check cannot modify a link or its grants.
Diagnostics and security events omit provider subjects and group names. Link events contain the provider ID, local user ID, result, and managed-grant count. Peryx persists the subject because the provider uses it as the stable lookup key.
The linking model is transport-neutral. OIDC and LDAP provide login transports; browser sessions and SCIM provisioning remain separate integrations.
Local password authentication
A server user may hold a local password. Enrollment derives a memory-hard verifier and discards the password, so the plaintext is never written; only the verifier is stored, beside the account and keyed by its stable ID.
Verifiers are Argon2id, the algorithm RFC 9106 standardizes, with the OWASP Password Storage baseline parameters by default: 19 MiB of memory, two iterations, and a single lane over a random 128-bit salt. Each verifier records the salt and parameters it was made with, so raising the policy does not invalidate the verifiers already stored.
Authentication takes a display name and a password and returns the stable user ID on success. An unknown name, a disabled account, an account with no password, and a wrong password all fail the same way: the same result and the same cost. A login without a stored verifier still spends one derivation against a decoy, so a caller cannot tell an absent account from a wrong password by watching how long the answer takes. An identity-store read that itself fails denies the login rather than falling through to success.
A successful login whose verifier no longer matches the current policy re-enrolls it under the same user ID before returning, so tightening the parameters upgrades verifiers as their owners sign in. A concurrent password change makes the conditional replacement fail and rejects the login instead of restoring the stale verifier.
Each hash and check runs on the blocking pool because Argon2id uses 19 MiB per derivation. Four jobs may run and four
may wait. Peryx rejects the next enrollment or authentication request with 503 Service Unavailable before Argon2
starts; disconnecting a queued request releases its place. Real checks, decoys, enrollment, and stale-verifier upgrades
share this process-wide admission bound.
Passwords and verifiers are secrets end to end: neither appears in logs, errors, diagnostics, or any serialized account view, and debug rendering redacts a verifier. Enrolling again replaces the verifier; clearing it removes password authentication entirely. Clearing and then enrolling a new password is the recovery path when a local password is lost. peryx provides no self-service reset, password-reset email, or browser session.
What this does not do
The model authorizes a client against peryx. It never sends a client's credential to an upstream: peryx reaches an
upstream with the stored per-index username, password, or token on the cached index, and a client's identity has
no bearing on that fetch. Client auth versus upstream credentials explains why
forwarding would be unsafe for a cache.
Related
- Ecosystem owner documentation
- Every other TOML key: configuration
- Security-event records for an authorization decision: logging