Fabriq
1.x
Docs/Fabriq/Authentication
Open

Reading9 min
Updated2 Aug 2026
Sourcev1/(admin)/authentication.mdx

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 Bearer credential 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:

KindBehaviour
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}       → revoke

Bootstrap. 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:

CapabilityActionResource type
analytics.adminadminanalytics
connections.readreadconnections

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-demo

With 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.