---
title: Authentication
description: Hosted-fabriq auth for the adminapi — per-tenant API keys for machine clients (verified by a Bearer middleware) and a username/password dashboard login that mints a short-lived session token validated by the same middleware. Both opt-in; off by default.
---

The [adminapi](/docs/fabriq/(operations)/remote-protocol) 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](/docs/fabriq/(admin)/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 keys

Enable the key layer by giving the adminapi a `KeyStore`:

```go
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):

```text
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 login

For an interactive login screen on the [console](/docs/fabriq/(admin)/admin-console), enable a
username/password credential (this requires `WithAuth` — login mints into the key store):

```go
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:

```text
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)

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`:

```go
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:

```go
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`:

```go
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`](https://github.com/xraph/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.

```go
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`:

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

`cmd/admin-demo` wires all of this from the environment:

```bash
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](/docs/fabriq/(admin)/connection-string) per tenant. Adding
`ADMIN_LOGIN_PASSWORD` enables the dashboard login. Unset, the demo is unauthenticated —
unchanged.
