Authentication & Authorization
This page covers how to turn on authentication, seed the first admin, and understand the session/token model well enough to deploy Elara from scratch.
Elara has two independent auth surfaces:
| Surface | Who uses it | Credential | Config toggle |
|---|---|---|---|
UI / ConnectRPC API (:8080) |
Operators via the Web UI, and CLI/API clients | A session (cookie or Bearer) | ui.auth.enabled |
etcd-compatible gRPC API (:2379) |
3rd-party services reading/writing config | A service token | client.auth.enabled |
These are configured separately. It is normal to enable UI auth while keeping the etcd port token-protected (production), or to disable both for a local demo.
Auth Types Overview
UI authentication has three types, selected by ui.auth.type when
ui.auth.enabled=true. When ui.auth.enabled=false, the type is forced to
none regardless of what ui.auth.type says.
| Type | ui.auth.type |
First admin comes from | Use when |
|---|---|---|---|
| None (passthrough) | none (or auth disabled) |
A synthetic local admin injected on every request | Local dev / demo only — no login, no protection |
| Basic Auth | basic-auth |
A local user seeded from configured username/password | Single-operator setups, air-gapped installs, quick production stand-up |
| OIDC | oidc |
The first login whose verified email matches adminEmail |
Real deployments with an existing IdP (Okta, Keycloak, Auth0, Google, …) |
The auth type is a domain constant (internal/domain/user.go):
AuthTypeNone = "none", AuthTypeBasicAuth = "basic-auth", AuthTypeOIDC = "oidc".
Configuration keys
Every key below is Viper-backed; the environment variable form (uppercased, dot → underscore) overrides file config.
| Config key | Env var | Notes |
|---|---|---|
ui.auth.enabled |
UI_AUTH_ENABLED |
Master switch for UI auth |
ui.auth.type |
UI_AUTH_TYPE |
none / basic-auth / oidc |
ui.auth.basicAuth.username |
UI_AUTH_BASICAUTH_USERNAME |
Required for basic-auth (must be email-shaped) |
ui.auth.basicAuth.password |
UI_AUTH_BASICAUTH_PASSWORD |
Required for basic-auth |
ui.auth.oidc.issuerUrl |
UI_AUTH_OIDC_ISSUERURL |
OIDC discovery issuer |
ui.auth.oidc.clientId |
UI_AUTH_OIDC_CLIENTID |
|
ui.auth.oidc.clientSecret |
UI_AUTH_OIDC_CLIENTSECRET |
|
ui.auth.oidc.redirectUrl |
UI_AUTH_OIDC_REDIRECTURL |
Must match the IdP-registered callback |
ui.auth.oidc.scopes |
UI_AUTH_OIDC_SCOPES |
Defaults to openid email profile |
ui.auth.oidc.adminEmail |
UI_AUTH_OIDC_ADMINEMAIL |
Required for oidc — bootstraps the first admin |
ui.auth.session.secureCookie |
UI_AUTH_SESSION_SECURECOOKIE |
See below |
client.auth.enabled |
CLIENT_AUTH_ENABLED |
Token auth for the etcd gRPC port |
secureCookie and plain HTTP
ui.auth.session.secureCookie marks the elara_session cookie with the
Secure flag. Set it to false for plain-HTTP local/dev setups —
browsers drop Secure cookies over HTTP (RFC 6265), so with it true on
HTTP you will never stay logged in. Set it to true in production
behind TLS.
Boot-time validation
Config is validated at startup (UIAuthConfig.Validate); the service refuses
to boot on a misconfiguration:
basic-authwithout a username →basic-auth requires ui.auth.basicAuth.username to be setbasic-authwithout a password →basic-auth requires ui.auth.basicAuth.password to be setoidcwithoutadminEmail→oidc requires ui.auth.oidc.adminEmail to be set
Basic Auth Setup
On startup with basic-auth, Elara seeds a single local admin user from the
configured credentials, adds it to the system superadmin group, and marks it as
a protected system user (User.System = true, so it can never be deactivated or
deleted through the API). The username must be email-shaped — it is
normalized into the user's login identity and fails fast at startup otherwise.
Environment
UI_AUTH_ENABLED=true
UI_AUTH_TYPE=basic-auth
UI_AUTH_BASICAUTH_USERNAME=admin@example.com
UI_AUTH_BASICAUTH_PASSWORD=change-me-now
# Plain HTTP locally? keep this false. Behind TLS in prod? set true.
UI_AUTH_SESSION_SECURECOOKIE=false
Required first step: forced password change
The bootstrap admin is created with PasswordChangeRequired = true. This is
enforced server-side at the auth interceptor, not merely a UI nag:
- Log in with the bootstrap credentials.
BasicLoginsucceeds and the response carriespasswordChangeRequired: true. - Until the password is changed, every other API call is rejected with
PermissionDenied/ "password change required". Only three procedures are permitted in this state:ProfileService/ChangePassword,ProfileService/Me, andProfileService/Logout. - Call
ProfileService.ChangePasswordwith anew_password. When the change is forced,current_passwordmay be left empty; supply it for a normal voluntary change later. A successful change clears the flag and issues a fresh session cookie.
Note
ProfileService.ChangePassword is only available when the auth type is
basic-auth. Under OIDC or passthrough it returns
InvalidArgument / "feature not available" — passwords are the IdP's job
(OIDC) or nonexistent (passthrough).
After the password change you have a normal authenticated session and full superadmin rights.
OIDC Setup
Under OIDC, Elara does not self-provision arbitrary users
(no JIT provisioning): an unknown identity is rejected with
ErrIdentityNotProvisioned. The one exception is the configured admin, which is
bootstrapped specially.
Environment
UI_AUTH_ENABLED=true
UI_AUTH_TYPE=oidc
UI_AUTH_OIDC_ISSUERURL=https://idp.example.com/
UI_AUTH_OIDC_CLIENTID=elara
UI_AUTH_OIDC_CLIENTSECRET=...
UI_AUTH_OIDC_REDIRECTURL=https://elara.example.com/api/auth/callback
UI_AUTH_OIDC_ADMINEMAIL=admin@example.com
UI_AUTH_SESSION_SECURECOOKIE=true
How the admin bootstrap actually works
This is a one-time link at bootstrap + first login, not a per-login check:
- At startup,
BootstrapOIDCcreates a placeholder system user whoseEmailequalsadminEmailand whose identity list is intentionally empty, then adds that placeholder to the superadmin group. The superadmin group and its wildcard policy are created here too. - On the first OIDC callback whose token carries a verified email
(
email_verified=true) equal toadminEmail, Elara's email-fallback linking attaches the real(oidc, subject)identity to that placeholder user. From that point the user is a fully-linked superadmin. - Subsequent logins resolve directly by
(provider, subject)— the admin email is not re-checked on every login. The membership rule is written once at bootstrap and is a protected system invariant; the runtime re-promotion that older designs had was removed.
An anti-hijack guard applies: if the target user already has an identity for the
same provider, a second IdP user presenting the same email cannot steal the
account (they are rejected with ErrIdentityNotProvisioned).
Non-admin users must be provisioned in Elara first (created as users with matching email), then linked on their first verified OIDC login the same way.
Passthrough / No-Auth Mode
Development only
Passthrough mode performs no authentication at all. Every request to the UI/ConnectRPC API is treated as a superadmin. Never expose a passthrough instance on a network anyone else can reach.
Enable it by simply leaving UI auth off:
UI_AUTH_ENABLED=false
# ui.auth.type is ignored and forced to "none"
How it works: at startup BootstrapPassthrough seeds a synthetic system user
(local-admin@elara.internal, User.System = true) into the superadmin group.
The auth interceptor runs in a bypass mode (skipPermissions=true) — any
request, authenticated or not, is rewritten with this synthetic admin context,
so enforcement uniformly resolves to superadmin. There is no login screen and no
session to obtain. A one-shot WARN is logged at startup so the bypass is
grep-able in production-shaped logs.
Because there is no real principal, the authorization PDP must also run with
permissions skipped — the two are wired in lockstep from the same
ui.auth.enabled flag, so you cannot accidentally get a half-open state through
config alone.
Sessions
A session is user authentication state for the UI/ConnectRPC API. It is
server-side and opaque: the id is a 256-bit cryptographically random value,
base64-URL-encoded — no JWT, no embedded claims. Sessions live in the bbolt
sessions bucket.
Two transports, one entity
| Client | How the session id travels |
|---|---|
| Web UI | elara_session cookie (HttpOnly, SameSite=Lax, Path=/, Secure per secureCookie) |
| CLI / API clients | Authorization: Bearer <session-id> header |
If both are present on one request, the Bearer header wins (it is checked before the cookie).
TTLs
Verified against internal/service/auth/sessions/model.go:
| Session type | Initial TTL | Sliding? | Hard cap |
|---|---|---|---|
Web (ClientTypeWeb) |
8 hours | Yes — extended on activity | 30 days from creation |
CLI (ClientTypeCLI) |
30 days | No (absolute in MVP) | 30 days |
Web sliding-TTL refresh is throttled (~60s) and only extends when the delta exceeds ~5 minutes, so an active session keeps rolling forward its 8-hour window until it hits the 30-day hard cap from creation.
Audit log
Every active state change appends a SessionEvent to the session_events
bucket (append-only audit trail). Event types:
created— a session was minted (login)refreshed— a sliding-TTL extension was applied (web only)revoked_by_user— user logged out / revoked their own sessionrevoked_by_admin— an admin revoked itrevoked_cascade— revoked as a side effect (e.g. user deactivation)
Passive expiration is not logged. When ExpiresAt < now the session simply
stops authenticating; the expiration moment is observable directly from the
row's ExpiresAt. The audit log records observed actions, not time-driven
transitions.
Enforcement on every request
The auth interceptor, per request: extract session id (Bearer beats cookie) →
SessionService.Validate (rejects not-found / revoked / expired) → load the user
→ reject if User.Status != active → best-effort throttled Refresh → inject
session + user into context. A deactivated user cannot make any authenticated
request even if they hold a still-valid session id.
Tokens (service credentials for etcd clients)
A token is a service credential for 3rd-party clients hitting the
etcd-compatible gRPC API (:2379). Tokens are not sessions and not
personal access tokens:
| Session | Token | |
|---|---|---|
| Purpose | User auth on the UI/ConnectRPC API | Service auth on the etcd gRPC API |
| Permissions | The user's group-derived RBAC | The token's own Role + Namespaces |
| Lifecycle | Tied to the user (deactivation cascades) | Independent of the issuing user |
| Storage | sessions bucket |
Token store, hashed (SHA-256) |
A token's permissions come only from its own fields, never inherited from the
issuer. Deactivating, renaming, or deleting the user who issued a token has no
effect on that token — tokens must be revoked explicitly via
TokenService.Revoke.
Token fields
Defined in internal/domain/token.go:
Role— eitherwriterorreaderonly (a token can never beadmin).reader→ read allowed.writer→ read + write allowed (write implies read).Namespaces— explicit non-empty list of namespaces the token may touch;*grants all namespaces (required for unbounded / scan-all watches).ExpiresAt— optional;nilmeans the token never expires.
A token can never grant more than its creator holds: issuing a writer token
for namespace prod requires the creator to have write on prod, otherwise the
request fails with a permission-escalation error.
Issuing a token
Via the Web UI (Tokens page) or TokenService.Create on the ConnectRPC API.
The raw token string is returned exactly once at creation (only its SHA-256
hash is stored) — copy it then; it cannot be retrieved again. Raw tokens are
prefixed elara_.
Using a token against the etcd gRPC API
Send it as gRPC metadata on the :2379 connection:
authorization: Bearer elara_<rest-of-token>
The gRPC token interceptor (internal/handler/etcdv3/interceptor/auth.go)
hashes the presented token, looks it up, rejects unknown (invalid token) or
expired (token expired) tokens, records last-used IP/time, and injects the
token's namespace/role claims. Every KV and Watch RPC is then checked against
those claims: reads need the namespace in scope; writes additionally need
writer role; scan-all watches need a * (wildcard) token.
With an etcd client library, set the metadata credential per your library's API
(e.g. a per-RPC credential that emits the authorization header). Example with
etcdctl-style usage is client-specific; the wire requirement is simply the
authorization: Bearer elara_… metadata pair above.
Enabling / Disabling etcd Client Auth
Token enforcement on the etcd gRPC port is gated by client.auth.enabled
(env CLIENT_AUTH_ENABLED):
CLIENT_AUTH_ENABLED=true # production: every etcd RPC needs a valid token
Never disable in production
With CLIENT_AUTH_ENABLED=false, the etcd-compatible API requires no
token at all — anyone who can reach port 2379 can read and write every
config in every namespace. This flag exists for local dev/demo only. Leave
it true anywhere the port is reachable by anything you don't fully
trust.
When disabled, the interceptor injects wildcard admin-equivalent claims for every call (nil claims are treated as "auth disabled → always allowed" in the per-namespace checks), which is why unauthenticated clients get full access.
Groups & Roles (making someone an admin)
Elara uses groups-only RBAC: users receive permissions only through group membership. There is no API to grant a role directly to a user. The rationale is documented in ADR 0002 — Groups-only RBAC; this section is the practical how-to.
Roles and what they grant
Roles (internal/domain/rbac.go) describe the object/action level a grant
covers. Permissions are stored as (object, action, domain) tuples, where
domain is a namespace (namespace:<name>) or the wildcard *:
| Role | Object / Action semantics |
|---|---|
admin |
All actions (*) on all objects (*) — full control |
writer |
write on content (write implies read) |
reader |
read on content |
Action semantics (ActionGrants): a write grant also satisfies read
(you cannot edit what you cannot read); create and delete are independent
capabilities. Objects include namespace, token, user, group, policy,
webhook, dashboard, plus the * wildcard.
The system superadmin group carries the single break-glass rule
(group:superadmin, *, *, *) — all objects, all actions, all namespaces — and
its members are global admins.
Practical flow: grant someone admin over a namespace (or globally)
- Create a group (Web UI Groups → Create, or
GroupService.Create). You can attach initial permissions and members atomically at creation time. - Assign the group a permission in a domain: pick the object/action and set
the domain to a specific namespace (
namespace:prod) or*for all namespaces. For a namespace admin, grant all-actions-on-all-objects scoped to that namespace; for a platform admin, scope it to*(or just add them to the built-in superadmin group). - Add the user to the group. Membership is what actually confers the permissions.
Anti-escalation is enforced throughout: you cannot grant a group (or its
members) any permission you do not yourself already hold. The bootstrap admin
(basic-auth local user, or the OIDC adminEmail identity) starts in the
superadmin group and can therefore set up all other groups.