Skip to content

Access Control

Zelavis uses one core access model for the control plane, project dashboards, service endpoints, CLI calls, AI agents, scripts, and future official modules such as Hosting Provider.

The dashboard is not the authority layer. Menus and project cards are filtered for usability, but endpoint access is enforced by the server contract.

The base model lives in zelavis/core:

  • a ZelavisPrincipal represents the caller
  • roles describe broad identity shape such as owner, operator, reseller, or customer
  • permissions describe concrete capabilities
  • grants attach permissions to a scope
  • access requirements are declared on routes and service-owned menu items

Scopes can be:

  • system for owner/operator actions that affect the whole Zelavis instance
  • project for project, site, app, and customer-owned resources
  • service for service/plugin-owned capabilities

Example route requirement:

{
id: "projects.content.update",
method: "PATCH",
path: "/:projectId/content/:entryId",
access: {
permissions: ["project.content.write"],
scope: { type: "project", projectIdParam: "projectId" },
},
handler({ principal, params }) {
return {
body: {
principalId: principal?.id,
projectId: params.projectId,
},
};
},
}

zelavis/app/identity owns authentication building blocks: accounts, credentials, sessions, and pluggable auth methods such as email/password, username/password, passkeys, OAuth, SSO, API keys, or service-token providers.

zelavis/core owns the lower-level authorization contract because every runtime service needs a way to declare and enforce access requirements without depending on one authentication method.

That separation keeps the core extensible:

  • a dashboard session can resolve a principal
  • an API key can resolve a principal
  • an AI agent token can resolve a principal
  • a Hosting Provider customer login can resolve a principal
  • a future SSO plugin can resolve a principal

All of those callers still pass through the same route access checks.

The runtime composes ZelavisRequestAuthenticator implementations directly on the standard Web Request boundary. No router framework owns authentication.

  • opaque App sessions use 256-bit random zvs_ tokens and persist only a SHA-256 token digest
  • App accounts, credentials, and sessions persist through the App’s tenant-routed database boundary
  • Platform accounts, credentials, and sessions persist separately in the System Store
  • session credentials may arrive through Authorization: Bearer or an HttpOnly, SameSite cookie that is Secure on HTTPS deployments
  • scripts receive the issued bearer token directly; a response sets the session cookie only for a request carrying a matching same-origin Origin
  • cookie-authenticated mutations require a matching same-origin Origin
  • first-owner bootstrap requires an operator-configured high-entropy token and a credential-enrollment provider; the token is never a normal login method
  • optional Basic, JWT, and remote-JWKS authenticators resolve into the same principal model
  • invalid credentials produce a 401 and the appropriate WWW-Authenticate challenge

Password sign-in is part of Zelavis rather than a plugin. An installation with no credential provider can never create its first owner, so it is not something an extension supplies. One provider covers both identifier kinds: an identifier containing @ is treated as an email address, anything else as a username. The password primitive uses salted PBKDF2-HMAC-SHA-256 over Web Crypto, and an unknown identifier is verified against a decoy hash so the response does not reveal whether an account exists.

The OAuth Authorization Code flow is core too — it holds the state, nonce and PKCE verifier, which are the parts that are dangerous to get wrong and the same for every provider. Any OpenID Connect issuer is added by pasting its issuer URL; a plugin declaring zelavis/identity:oauth is needed only for a provider that is not OIDC, where the profile endpoint and claim mapping are real code. The OIDC plugin under plugins/ is now only a bearer-token authenticator for callers already holding a token from an issuer on top of that verifier.

The same /zelavis shell can render different views depending on the current principal:

  • owner or superadmin: all projects plus server, domains, resources, security, marketplace, and project controls
  • operator: delegated server/project controls
  • reseller: projects and customers owned by that reseller
  • customer: only their projects and allowed project actions

Project creation can assign an initial Project-owner grant, but the creator ID is not an authorization shortcut. Owners and superadmins can see all Projects regardless of who created them; every other inventory item and action is filtered by explicit grants and still enforced by its endpoint.

This is the intended foundation for a future official Hosting Provider module. The module should not create a separate customer permission system. It should create customers, subscriptions, packages, and grants that feed the same core principal model.

Access itself is a native Platform capability, implemented with the reusable access contracts from zelavis/core. Its dashboard navigation is contributed by @zelavis/ui through the normal service menu API on the platform surface, which is reserved for bundled or statically trusted system services that belong in the global /zelavis management shell. Runtime-installed marketplace plugins still mount under Extensions.

GET /zelavis/api/v1/auth/bootstrap reports whether the installation still needs its first owner and which installed auth providers support credential enrollment. POST /zelavis/api/v1/auth/bootstrap is disabled unless the operator configured ZELAVIS_BOOTSTRAP_TOKEN or bootstrap.token; successful bootstrap consumes an installed provider’s enrollment contract and issues the first owner session.

The dashboard login screen calls the same versioned Auth endpoints as other clients. /zelavis/api/v1/runtime/access requires authentication and returns the real session principal. There is no demo-owner fallback or query-string identity switch.

A Project’s records are not part of its control plane, and the permissions say so:

  • project.view carries database.inspect and database.read — an operator browsing an installation’s tables, including other Tenants’.
  • project.runtime.manage carries database.write, alongside the workload and storage authority that managing a runtime implies.
  • project.data.read and project.data.write carry database.read and database.write and nothing else.

The last pair exists so an App can hold its own data without holding authority over the Project it lives in. A Gateway request for App data forwards only those two implications, even when the caller happens to hold Project authority as well, so an App data path cannot spend a permission it did not come to use.

The Tenant travels the same way. The Platform resolves it from the authenticated principal and signs it into the Gateway envelope; a Project service reads it from the principal rather than from the request. A caller may still name a Tenant, but naming one that is not its own requires database.inspect and is otherwise refused. See App Data.

Any service or plugin that performs a privileged action should:

  1. Define a capability in the service/runtime layer.
  2. Expose it through a stable endpoint.
  3. Declare route access requirements.
  4. Optionally declare menu access requirements so the dashboard can hide unavailable UI.
  5. Never rely on hidden dashboard state as the only protection.