Skip to main content

Admin API

The admin API is reached at https://admin.pkg.example.org/api/v1/ (or whatever value you set for ADMIN_DOMAIN in .env). Every endpoint other than /api/v1/auth/login/{provider} and /api/v1/auth/callback/{provider} requires an authenticated operator session.

Operators sign in via OAuth (GitHub or Microsoft) and receive an HttpOnly, SameSite=Strict session cookie. Sessions are server-side and revocable; there is no API token or break-glass path. See Operator onboarding for the human workflow.

Authentication and session model

OAuth login

MethodPathDescription
GET/api/v1/auth/login/githubInitiate the GitHub OAuth flow; sets a transient handle cookie
GET/api/v1/auth/login/microsoftInitiate the Microsoft Entra OAuth flow
GET/api/v1/auth/callback/{provider}OAuth callback — the IdP redirects the browser here
POST/api/v1/auth/logoutDestroy the current session and clear the cookie
GET/api/v1/auth/whoamiReturn the current operator's {id, email, role, status, …}

Both login endpoints use the Authorization Code + PKCE (S256) flow. The state parameter and PKCE code_verifier are stored server-side and keyed by an opaque handle held in a short-lived cookie so the browser doesn't see the verifier. The callback validates the state, exchanges the code, resolves the operator by canonical email against the allowlist (operators table), and creates a session.

Set on successful callback as Set-Cookie: packyard_session=…:

AttributeValueWhy
HttpOnlyyesJavaScript cannot read the cookie — bounds XSS impact
SecureyesOnly sent over TLS
SameSiteStrictCross-site requests do not carry the cookie (CSRF defence)
Path/Both /admin/* (SPA) and /api/v1/* (API) need the cookie
Max-Age24 h (absolute)Absolute session lifetime — re-login required after that

In addition to Max-Age, the server enforces an 8-hour idle timeout. Each authenticated request bumps last_seen_at; if that field is older than 8 hours when the next request arrives, the session is rejected with SESSION_EXPIRED.

CSRF defence

Every mutating request (POST/PUT/PATCH/DELETE) must carry an Origin header (or a Referer header, when Origin is absent) whose scheme + host + port match PACKYARD_ADMIN_HOST. Failure returns 403 CSRF_DENIED. Same-origin requests from the SPA always pass; this layer exists in addition to SameSite=Strict so a misconfigured proxy or custom client still has to carry a matching origin header.

Rate limit

The OAuth surface (/api/v1/auth/login/{provider} and /api/v1/auth/callback/{provider}) is rate-limited per source IP via a token bucket — capacity 10, refill 1 request every 6 seconds. Exceeding the bucket returns 429 RATE_LIMITED and writes an auth.rate_limited audit row (coalesced per source IP within a 60-second window so a sustained attack does not flood the audit log). No other admin endpoints are rate-limited — they sit behind the session check, which already bounds unauthenticated abuse.

Pagination

All list endpoints (/accounts, /accounts/{id}/keys, /keys, /operators, /audit) accept:

ParamDefaultMaxBehaviour
offset0Zero-based row offset
limit50500Number of rows; limit > 500 returns 400 LIMIT_TOO_LARGE

Responses include an RFC 5988 Link header with rel="prev" and rel="next" URLs preserving any filter parameters. The SPA uses these directly; CLI users can parse them with curl -i and awk/jq. The response body itself is a plain JSON array — no envelope object, no total_count.

Accounts

Accounts are subscriber identities that own subscription keys. Every key must belong to exactly one account; deleting an account revokes its keys.

MethodPathDescription
POST/api/v1/accountsCreate a new subscriber account
GET/api/v1/accountsList accounts (filter via ?status=)
GET/api/v1/accounts/{id}Get a single account
PATCH/api/v1/accounts/{id}Update email / org / status
DELETE/api/v1/accounts/{id}?confirm={id}Delete the account and revoke every active key
GET/api/v1/accounts/{id}/keysList keys owned by the account
POST/api/v1/accounts/{id}/keysIssue a new key for the account

The status field is one of active, suspended, deleted. Deleted accounts are not returned by list endpoints; only an exact-id lookup surfaces them.

Create an account

curl -s -X POST https://admin.pkg.example.org/api/v1/accounts \
-H 'Content-Type: application/json' \
--cookie "$COOKIE" \
-d '{"email":"ops@acme.test","org_name":"Acme Corp"}' | jq .
{
"id": "01J4Y8K7M3Q5RXY2N9P4VWZB6T",
"email": "ops@acme.test",
"org_name": "Acme Corp",
"status": "active",
"created_at": "2026-05-22T12:00:00Z",
"created_by_operator_id": "9f3c2a8b1d4e5f60"
}

Issue a key for an account

curl -s -X POST "https://admin.pkg.example.org/api/v1/accounts/${ACCOUNT_ID}/keys" \
-H 'Content-Type: application/json' \
--cookie "$COOKIE" \
-d '{"component":"core","label":"prod"}' | jq .
{
"id": "abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890",
"component": "core",
"active": true,
"label": "prod",
"created_at": "2026-05-22T12:00:00Z",
"expires_at": null,
"usage_count": 0,
"account_id": "01J4Y8K7M3Q5RXY2N9P4VWZB6T",
"component_visibility": "private"
}

The id field is the subscription key — there is no separate secret. It is the HTTP Basic password the subscriber pastes into their package manager configuration verbatim. Capture it at creation; subsequent list / get responses include the same id alongside metadata, so it is never secret-by-obscurity, but rotation requires revoking the key and issuing a fresh one.

Delete an account (with safe-lock)

Bare DELETE returns 409 CONFIRM_REQUIRED with an impact preview:

curl -s -X DELETE "https://admin.pkg.example.org/api/v1/accounts/${ACCOUNT_ID}" \
--cookie "$COOKIE" | jq .
{
"code": "CONFIRM_REQUIRED",
"message": "repeat the request with ?confirm=<id> to proceed",
"impact": { "keys_revoked": 3 }
}

Re-issue with ?confirm={id} to proceed:

curl -s -X DELETE "https://admin.pkg.example.org/api/v1/accounts/${ACCOUNT_ID}?confirm=${ACCOUNT_ID}" \
--cookie "$COOKIE" | jq .
# { "keys_revoked": 3 }

Subscription keys

Keys are scoped to a single component and a single account. The bulk endpoints below remain useful for queries; routine issuance goes through /accounts/{id}/keys so the account binding is explicit.

MethodPathDescription
POST/api/v1/keysCreate a key (account_id required in body)
GET/api/v1/keysList keys (filter via ?component= and/or ?account=)
GET/api/v1/keys/{id}Inspect a single key
DELETE/api/v1/keys/{id}Revoke a key

Components

Components are provisioned via the admin API and stored in the SQLite database. Forward-auth resolves component visibility via a live database lookup on every request; the GET /api/v1/keys?component= filter and component_visibility field in key responses use a snapshot loaded at startup (restart required to pick up new components in those paths).

MethodPathDescription
POST/api/v1/componentsProvision a new component
GET/api/v1/componentsList all components
GET/api/v1/components/{name}Get a single component
PATCH/api/v1/components/{name}Update component visibility
DELETE/api/v1/components/{name}Deprovision a component (safe-lock)

A key scoped to core grants access only to /rpm/core/, /deb/core/, and lts-core OCI paths. Cross-component access is denied — a core key cannot access /rpm/minion/. The component name in the key must match the path segment exactly.

Component visibility

ValueBehaviour
privateCredentials are required and scope-checked (default)
publicRequests are allowed without credentials — credentials, if present, are ignored

Public components are useful for freely distributable software. The auth service returns 200 for any request to a public component path, regardless of whether credentials are present or valid.

Provisioning a component

curl -s -X POST https://admin.pkg.example.org/api/v1/components \
-H 'Content-Type: application/json' \
--cookie "$COOKIE" \
-d '{
"name": "minion",
"visibility": "private",
"rpm_series": ["2025"],
"rpm_os_families": ["el9"],
"rpm_architectures": ["x86_64", "aarch64"]
}' | jq .

Validation rules: name must not contain /, \, or ..; visibility must be public or private; series/families/architectures values are also rejected if they contain path-unsafe characters.

Deprovisioning a component

Same two-step safe-lock as accounts:

curl -s -X DELETE https://admin.pkg.example.org/api/v1/components/minion --cookie "$COOKIE" | jq .
# 409 CONFIRM_REQUIRED with impact preview

curl -s -X DELETE 'https://admin.pkg.example.org/api/v1/components/minion?confirm=minion' --cookie "$COOKIE" | jq .
# 200 OK with { "keys_revoked": N }

RPM directory content is not removed automatically — archive packages before deleting the directory.

Updating a component

PATCH /api/v1/components/{name} updates mutable fields. Currently only visibility may be changed. The update is persisted immediately and takes effect on the next subscriber request — no restart required.

curl -s -X PATCH https://admin.pkg.example.org/api/v1/components/minion \
-H 'Content-Type: application/json' \
--cookie "$COOKIE" \
-d '{"visibility":"public"}' | jq .

Operators

MethodPathDescription
GET/api/v1/operatorsList operators (paginated)
POST/api/v1/operatorsAllowlist a new operator (admin-only)
PATCH/api/v1/operators/{id}Change role and/or status (admin-only, self-lockout-guarded)

POST /api/v1/operators accepts {"email": "...", "role": "admin"|"readonly"}. Role defaults to admin when omitted. Email is canonicalised (lowercase + trim).

PATCH /api/v1/operators/{id} accepts {"role": "...", "status": "..."}. At least one of the two must be set. The PATCH wraps the count guard + role + status update in a single serializable transaction; a mutation that would leave zero active admins returns 403 OPERATOR_SELF_LOCKOUT. A demote (admin → readonly) or disable (active → disabled) deletes the target's active sessions so the change takes effect on the next request.

There is no DELETE for operators by design — disabling preserves the audit-log attribution. See the operator onboarding guide for the full flow.

Audit log

MethodPathDescription
GET/api/v1/auditList audit rows (readable by admin and readonly)

Filters (all optional, AND-combined):

ParamDescription
operatorRestrict to actions by this operator id
actionExact action name (e.g. account.create, operator.role_change)
target_typeRestrict to a target type (e.g. account, operator, key, session)
target_idRestrict to a target id (combine with target_type for unambiguous match)
sinceRFC 3339 timestamp — inclusive lower bound
untilRFC 3339 timestamp — exclusive upper bound
offset,limitPagination per § Pagination

Audit rows are append-only — no PUT/PATCH/DELETE exists for them.

Action vocabulary (non-exhaustive — see the operator-authn spec for the authoritative list): login.success, login.failure, logout, auth.role_denied, auth.rate_limited, account.create, account.update, account.suspend, account.reactivate, account.delete, key.issue, key.revoke, operator.add, operator.disable, operator.enable, operator.role_change.

Error responses

Every JSON error has the same envelope:

{
"code": "OPERATOR_SELF_LOCKOUT",
"message": "refusing to leave zero active admins; ask another admin to make this change"
}

Additional context fields may be present (e.g. impact on CONFIRM_REQUIRED, component_requested / key_scope on forward-auth rejection).

Authentication / authorisation codes

CodeHTTPWhen
UNAUTHORIZED401No valid operator session
SESSION_EXPIRED401Session exceeded idle or absolute timeout
ROLE_DENIED403Role does not permit the requested operation
OPERATOR_NOT_ALLOWED403Email is not in the operators allowlist
OPERATOR_DISABLED403Operator exists but status = 'disabled'
ORG_MEMBERSHIP_REQUIRED403OAuth user is not a member of the configured GitHub org
EMAIL_NOT_VERIFIED403OAuth provider returned an unverified email
INVALID_OAUTH_STATE400OAuth state missing, mismatched, or replayed
RATE_LIMITED429Token-bucket capacity exceeded on /api/v1/auth/*
CSRF_DENIED403Origin / Referer does not match the configured admin host
UNKNOWN_PROVIDER404OAuth path named an unconfigured provider
OAUTH_EXCHANGE_FAILED401OAuth code-for-token exchange failed
LOGIN_INIT_FAILED500Internal entropy or store failure starting an OAuth flow
OPERATOR_LOOKUP_FAILED500Backend store error resolving an operator
SESSION_CREATE_FAILED500Backend store error creating a session
SESSION_LOOKUP_FAILED500Transient backend error during session middleware lookup

Resource / validation codes

CodeHTTPWhen
ACCOUNT_NOT_FOUND404No account with the requested id
ACCOUNT_RESERVED403Mutation rejected because the target is the synthetic legacy account
ACCOUNT_SUSPENDED409Key issuance rejected because the owning account is suspended
ACCOUNT_EMAIL_EXISTS409Account email collides with an existing record
MISSING_ACCOUNT_ID400POST /api/v1/keys called without account_id
INVALID_STATUS_TRANSITION400Attempted PATCH to/from deleted
CONFIRM_REQUIRED409Destructive endpoint called without matching ?confirm=
LIMIT_TOO_LARGE400List endpoint ?limit exceeds 500
OPERATOR_EMAIL_EXISTS409Operator email already in allowlist
OPERATOR_NOT_FOUND404No operator with the requested id
OPERATOR_SELF_LOCKOUT403PATCH would leave zero active admins
COMPONENT_EXISTS409A component with the given name already exists
COMPONENT_NOT_FOUND404No component with the given name exists
INVALID_VISIBILITY400visibility is not public or private
INVALID_REQUEST400Body is not JSON, or a field is missing / malformed

Package serving endpoints (/rpm/, /deb/, /oci/) return a bare HTTP 401 on auth failure — dnf/apt/docker do not parse response bodies on auth failure.