The adminapi is auth-agnostic and opt-in. Out of the box it performs no authentication — the host attaches whatever it wants through route middleware. Fabriq ships an optional, self-contained auth layer with two modes that share one verification path:
API keys — for machine clients (the connection string, remote SDKs, agents). A
Bearercredential the server verifies.Dashboard login — for humans. Username/password that exchanges for a short-lived session token the same key-verify middleware validates.
With neither enabled, the console loads directly and every route behaves exactly as before.
API keys01
Enable the key layer by giving the adminapi a KeyStore:
store := adminapi.NewKeyStore(groveDB) // backed by the fabriq_api_key table
ext := adminapi.NewAdminAPI(fabricExt, adminapi.WithAuth(store))When WithAuth is set, a verify middleware is installed on every admin route. Each
request must carry Authorization: Bearer <key>; the middleware looks the key up by hash,
rejects unknown / revoked / expired keys with 401, resolves the tenant, and stamps it
onto the request context.
Tenant-bound vs multi-tenant keys
A key record carries a nullable tenant_id:
| Kind | Behaviour |
Tenant-bound (tenant_id set) | Presenting the key authenticates and selects that tenant. A conflicting X-Tenant-ID header is a 403. |
Multi-tenant (tenant_id null) | Authenticates a principal that may act across tenants; the request must supply an X-Tenant-ID selector (400 if absent). |
Keys are stored as a sha256 hash plus a short display prefix — the plaintext is returned
once, at issue time, and never persisted.
Managing keys
When auth is enabled, a management surface is registered (gated on the can_manage_keys
flag):
POST {base}/keys → issue {label, tenantId?, canManageKeys?} → 201 {id, prefix, key}
GET {base}/keys → list (redacted: no hash, no plaintext)
DELETE {base}/keys/{id} → revokeBootstrap. On start with auth enabled, if FABRIQ_ADMIN_KEY is set it is registered as
a multi-tenant admin key; otherwise, if no admin key exists, one is generated and logged
once so an operator can bootstrap.
Wire version
Every response carries X-Fabriq-Api-Version. A request whose major version disagrees with
the server is rejected with 426 Upgrade Required; an absent header is tolerated.
Dashboard login02
For an interactive login screen on the console, enable a
username/password credential (this requires WithAuth — login mints into the key store):
ext := adminapi.NewAdminAPI(
fabricExt,
adminapi.WithAuth(store),
adminapi.WithAdminLogin("admin", os.Getenv("ADMIN_PASSWORD")),
)The password is bcrypt-hashed at startup (never stored in plaintext), and login is constant-time. Two routes are added:
POST {base}/login {username, password} → 201 {token, expiresAt} (exempt from the verify middleware)
POST {base}/logout → 200 (revokes the presented session)A successful login mints a session token — a key-store row that is multi-tenant,
can_manage_keys, and carries an expires_at (default 12h). The console stores it and
sends it as Authorization: Bearer <token>, so the ordinary key-verify middleware
validates every subsequent request; an expired or logged-out session simply fails
verification (401) and the console returns to the login screen. Because the session is
multi-tenant, the console's tenant switcher selects the working tenant per request.
POST {base}/login is the only route exempt from the verify middleware — it is the way in.
Everything else, including /logout, stays gated.
Custom authorization (per-user RBAC)03
Authentication (above) answers who is calling. Authorization answers what they may
do, and it is a separate, opt-in seam. Every admin capability gate — analytics.admin,
analytics.read, schema.admin, tenants.admin, connections.read — is routed through
one Authorizer:
type Authorizer interface {
Authorize(ctx context.Context, capability string) (allowed bool, err error)
}By default, the adminapi wraps the host-enablement flags (WithAnalyticsAdmin,
WithSchemaAdmin, WithTenantsAdmin, WithConnectionsRead, ...) in a flagAuthorizer,
so a gate is a static, all-or-nothing switch — identical to the pre-RBAC behavior.
WithAuthorizer overrides that default with a per-request decision:
ext := adminapi.NewAdminAPI(fabricExt, adminapi.WithAuthorizer(myAuthorizer))Once set, every gated route calls Authorize(ctx, capability) on each request instead of
reading the static flag. ctx is the request context — the resolved tenant and API-key id
are already on it, along with any principal a host authz middleware stamped before the
handler ran, so a per-tenant or per-user authz system can read whatever it needs from
there. Gated routes are always registered — a denial is a 403, never a 404 — and the
enablement flags only supply the default decision when no Authorizer is configured.
Fail closed. If Authorize returns a non-nil err, the gate returns 500 and the
call is denied — even if allowed was also true. An authorization backend that is down,
times out, or panics-and-recovers-into-an-error must never be treated as "allow"; the
error always dominates.
/meta reflects the caller. GET {base}/meta calls the configured Authorizer for
every gated capability and returns only the ones the current caller is authorized for.
Two callers hitting the same server with different principals see different
capabilities lists — the console uses this to hide admin affordances the caller can't
use.
Reference adapter: warden (not compiled into fabriq)
Fabriq imports no authorization system — Authorizer is the entire contract. Wiring a
real RBAC/ABAC engine (e.g. a warden-style permission service) is entirely the host's
job: the host owns both the adapter and the capability → permission mapping. A typical
adapter is a few lines using AuthorizerFunc:
adminapi.WithAuthorizer(adminapi.AuthorizerFunc(func(ctx context.Context, cap string) (bool, error) {
// warden's forge middleware has already put the principal in ctx.
return warden.Check(ctx, wardenPermissionFor(cap))
}))wardenPermissionFor is host code that maps fabriq's capability strings
("analytics.admin", "schema.admin", ...) onto whatever permission model warden (or any
other authz system) uses. The roll-your-own snippet above is illustrative — fabriq does not
depend on warden. For hosts that do run warden, a
compiled adapter ships alongside fabriq instead.
Compiled adapter: adapters/wardenauthz
github.com/xraph/fabriq/adapters/wardenauthz is a separate, opt-in Go module (its own
go.mod, nested under adapters/wardenauthz in this repo) that bridges adminapi.Authorizer
to a *warden.Engine. Fabriq core still imports no warden — only a host that pulls in this
module, in addition to fabriq, links warden in.
import (
"github.com/xraph/fabriq/adapters/wardenauthz"
"github.com/xraph/fabriq/forgeext/adminapi"
)
ext := adminapi.NewAdminAPI(fabricExt, adminapi.WithAuthorizer(wardenauthz.New(engine)))engine is the host's own *warden.Engine; New panics on a nil engine so miswiring
fails at startup, not at the first request. The returned *wardenauthz.Authorizer exposes
Authorize(ctx context.Context, capability string) (bool, error) and so satisfies
adminapi.Authorizer structurally — no interface embedding needed.
Default capability mapping. With no options, wardenauthz maps a fabriq capability to a
warden (action, resourceType) pair by splitting on the last dot — it expects a dotted
resource.action capability:
| Capability | Action | Resource type |
analytics.admin | admin | analytics |
connections.read | read | connections |
resourceID is always empty by default — the adapter checks the capability generically, not
against a specific tenant-scoped resource instance.
A dot-less (or leading/trailing-dot) capability is not a supported default. query has
no dot, so DefaultMapper returns action query and an empty resource type; a trailing
dot (analytics.) empties the action instead, and a leading dot (.read) empties the
resource type. warden.Engine.Check rejects an empty Action.Name or Resource.Type, so
Authorize returns (false, err) for these — a fail-closed 500, not a clean allow/deny.
Fabriq's own capabilities (analytics.admin, connections.read, query.raw, ...) are all
dotted, so this never happens with fabriq's built-in gates, but a host whose own capability
strings are not already resource.action must supply a custom Mapper rather than relying
on DefaultMapper.
Default subject. The adapter reads the caller from forge.UserIDFromContext(ctx) and
checks as warden.Subject{Kind: warden.SubjectUser, ID: uid}. When no user id is on the
context, it checks as the anonymous subject warden.Subject{Kind: "unknown", ID: "anonymous"}
— so an unauthenticated request is whatever warden's policy says unknown/anonymous may do,
not an automatic deny.
Tenant scope. Warden resolves the tenant it checks against from its own context scope
(warden.WithTenant) or an explicit CheckRequest.TenantID — neither of which wardenauthz
sets, and both are separate from fabriq's own tenant context key — so the host's warden
middleware must put warden's tenant scope onto the request context before the adminapi
handler runs (a Mapper can't do this: it only returns action/resourceType/resourceID,
it doesn't get to rewrite the ctx that Authorize later passes to Check), or every check
runs tenant-less against whatever warden's policy allows with no tenant scoped.
Overriding the mapping or subject. A host that wants tenant-scoped resource IDs, a
different action naming scheme, or a subject resolved from something other than
forge.UserIDFromContext supplies its own Mapper and/or SubjectFunc:
ext := adminapi.NewAdminAPI(fabricExt, adminapi.WithAuthorizer(
wardenauthz.New(engine,
wardenauthz.WithMapper(func(ctx context.Context, capability string) (action, resourceType, resourceID string) {
action, resourceType, _ = wardenauthz.DefaultMapper(ctx, capability)
return action, resourceType, tenantIDFromContext(ctx) // scope the resource to the caller's tenant
}),
wardenauthz.WithSubjectFunc(func(ctx context.Context) warden.Subject {
return warden.Subject{Kind: warden.SubjectUser, ID: principalFromContext(ctx)}
}),
),
))Mapper is func(ctx context.Context, capability string) (action, resourceType, resourceID string);
SubjectFunc is func(ctx context.Context) warden.Subject. Both options are no-ops when
passed nil, so partial overrides (mapper only, or subject only) are safe.
As with any Authorizer, a warden error propagates as (false, err) and fabriq's gate turns
that into a fail-closed 500 — see Fail closed, above. An ordinary "not allowed" (no
error, Allowed: false) is the usual 403.
In the demo04
cmd/admin-demo wires all of this from the environment:
ADMIN_DEMO_AUTH=1 \
ADMIN_LOGIN_USER=admin ADMIN_LOGIN_PASSWORD=s3cret \
go run ./cmd/admin-demoWith ADMIN_DEMO_AUTH=1 it enables WithAuth, seeds a per-tenant admin key, and prints a
ready-to-paste connection string per tenant. Adding
ADMIN_LOGIN_PASSWORD enables the dashboard login. Unset, the demo is unauthenticated —
unchanged.