Repository navigation
Authentication And Authorization
This document describes the current Felix authentication and authorization design as implemented in the control plane and the broker. It aligns with the felix-authz crate, Casbin RBAC model, and the token exchange flow in the control plane.
Detailed RBAC mutation/delegation rules and canonical grammar are documented in docs/security/rbac.md.
- Multi-tenant authentication and authorization.
- Support multiple OIDC issuers per tenant.
- Brokers validate only Felix tokens (never upstream IdP tokens).
- Embed effective permissions in Felix tokens for fast broker-side enforcement.
- Control plane: Validates upstream OIDC tokens, evaluates RBAC policies, and mints Felix tokens.
- felix-authz: JWT mint/verify utilities and permission parsing/matching shared by control plane and broker.
- Broker: Verifies Felix token signature/claims and enforces permissions locally.
Felix supports any OIDC-compliant IdP that exposes a JWKS endpoint (either via OIDC discovery or a direct jwks_url) and uses an allowed upstream OIDC signing algorithm.
Supported upstream OIDC JWT signing algorithms:
-
ES256(default) -
RS256,RS384,RS512 -
PS256,PS384,PS512
Control plane configuration:
- YAML:
oidc_allowed_algorithms: ["ES256"] - Env:
FELIX_CONTROLPLANE_OIDC_ALLOWED_ALGORITHMS=ES256,RS256,...
This includes common providers like:
- Microsoft Entra ID
- Okta
- Auth0
- Apple
Other providers are supported as long as they provide standard OIDC discovery or a JWKS URL and the tokens include iss, sub, and aud claims.
- Provided by an external IdP (Entra ID, Okta, Auth0, Google, Apple, etc.).
- Validated by the control plane using issuer discovery + JWKS.
- Claims used:
-
iss(issuer) -
sub(subject) - optional groups/roles claim
-
Minted by the control plane and verified by brokers.
Claims:
-
iss:felix-auth -
aud:felix-broker -
sub:principal_id(sha256 ofiss|sub) -
tid: tenant id -
exp,iat -
perms: array of permission strings
Algorithm:
- EdDSA (Ed25519) (tenant-specific signing key).
- Header includes
kid.
-
principal_id = sha256(iss + "|" + sub)(hex encoding). - Groups/roles are extracted from a configured claim (optional).
- During token exchange, each extracted group is added as a transient role link:
g, <principal_id>, group:<group_name>, <tenant>- This allows RBAC bindings like
g, group:g1, role:reader, <tenant>.
Casbin is used with domains for tenant scoping.
- Domain: tenant id
-
Role link:
g = user, role, domain -
Policy rule:
p = role, domain, object, action -
Matcher:
keyMatch2for object pattern matching
Objects:
- Tenant:
tenant:{tenant_id} - Namespace:
namespace:{tenant_id}/{namespace}ornamespace:{tenant_id}/* - Stream:
stream:{tenant_id}/{namespace}/{stream},stream:{tenant_id}/{namespace}/*, orstream:{tenant_id}/*/* - Cache:
cache:{tenant_id}/{namespace}/{cache},cache:{tenant_id}/{namespace}/*, orcache:{tenant_id}/*/* - Cluster:
cluster:*— see Cluster scope - Node:
node:{node_id}— one broker
Actions:
-
rbac.view,rbac.policy.manage,rbac.assignment.manage -
tenant.manage— overtenant:{tenant_id}for the tenant's own settings, or overcluster:*for the tenant catalog (create, list, delete) -
ns.manage,stream.manage,cache.manage— the control plane's resource API, over the namespace, stream or cache being changed -
stream.publish,stream.subscribe -
cache.read,cache.write -
node.view— cluster-scoped only -
node.manage— overnode:{node_id}orcluster:*
Consumer-group operations have no action of their own: a group is a read
position over a stream, so poll, acknowledge, hand-back and the dead-letter
requests are all authorized as stream.subscribe on the stream being read.
One object sits outside the tenant hierarchy:
- Cluster:
cluster:*— broker membership, liveness, and placement standing; the tenant catalog; and the metadata feeds brokers seed from.
It is an island in both directions, and that is the whole security property:
-
No tenant scope contains it. A policy write is admitted only when its
object is already inside the caller's scope, so a tenant admin cannot grant
themselves
cluster:*. Nothing in the bootstrap seed grants it either. - It contains no tenant object. Cluster scope confers nothing inside a tenant, so it is not a backdoor into tenant data.
node.view:cluster:* is required by GET /v1/nodes, GET /v1/nodes/{node_id},
and every snapshot and changes feed; tenant.manage:cluster:* by
GET/POST /v1/tenants and DELETE /v1/tenants/{tenant_id}. The tenant comes
from the token's own tid claim rather than a path segment, because the
cluster is not a tenant resource; that claim only selects which tenant's
signing keys to verify against, exactly as kid selects a key without
conferring one.
Cluster scope reaches a token only through bootstrap: initialize an operator tenant with policies granting the cluster actions to a role, assign the operator principal to it, and exchange. A tenant admin cannot write those rules later, for the reason above. The full per-endpoint table is in control-plane.md.
- RBAC mutation is not authorized by resource-management actions.
- Example:
ns.managedoes not permit policy/grouping writes.
- Example:
- RBAC APIs are split by intent:
- list policies/groupings:
rbac.view - mutate policy rules:
rbac.policy.manage - mutate assignments/groupings:
rbac.assignment.manage
- list policies/groupings:
- Delegation is scope-bound:
- callers can only create/delete rules within their own object scope
- callers cannot assign a role if any role policy exceeds caller scope
- Write-time validation rejects broad or malformed wildcards (for example
tenant:*).
When building effective permissions for a principal:
-
tenant.manage:tenant:{T}implies tenant-scoped namespace/stream/cache manage + read/write actions. -
ns.manage:namespace:{T}/{X}implies stream/cache manage + read/write within namespace{X}.
You can assign roles to IdP groups without per-user RBAC bindings:
- Add RBAC grouping rules that bind
group:<name>to a role. - Configure
groups_claimfor the tenant IdP issuer. - At token exchange time, Felix maps each incoming group value to
group:<name>(if not already prefixed) and evaluates RBAC through that link.
Example:
g, group:g1, role:reader, tenant-ap, role:reader, tenant-a, stream:tenant-a/payments/*, stream.subscribe
- Client obtains an upstream OIDC JWT from its IdP.
- Client calls:
POST /v1/tenants/{tenant_id}/token/exchangeAuthorization: Bearer <oidc_jwt>
- Control plane validates:
-
issmatches a tenant-allowed issuer - signature using JWKS (cached with TTL)
-
exp/nbfwith clock skew -
audmatches configured audiences
-
- Control plane derives
principal_idand loads RBAC policies/groupings for the tenant. - Effective permissions are computed from Casbin and expanded for inheritance.
- Optional request filters reduce the permission set (
requested/resources). - A Felix access token is minted, along with a refresh token, and both are returned.
If no permissions remain, the exchange returns 403.
The access token is deliberately short-lived (900s), because a leaked bearer token is only as dangerous as the time it stays valid. That is the right trade for a caller that can re-exchange freely and the wrong one for anything long-running — a broker holding a 900s node token drops out of the cluster in fifteen minutes. Raising the TTL trades the problem for a longer-lived secret.
Refresh is the answer instead. Exchange hands back a refresh token; presenting
it to POST /v1/tenants/{tenant_id}/token/refresh mints a new access token
with no upstream IdP round trip.
Three properties make that safe:
- The secret is never stored. The control plane keeps a SHA-256 of it, so a database read yields nothing presentable.
- Every refresh token is single-use. Refreshing spends the one presented and hands back its replacement. A stolen copy is worth one use, not a month of them.
- A replay revokes the whole chain. Nobody legitimately presents a spent token, so seeing one means two parties hold the chain. Both lose it, and the genuine holder re-exchanges. This is what makes single use detect theft rather than merely limit it.
Permissions are re-evaluated against current RBAC on every refresh, never carried over from the previous token. A grant removed after a token was issued stops working at the next refresh rather than whenever the caller happens to re-exchange — a refresh that froze its grants would turn the short access TTL into a long one for authorization purposes, which is most of what the short TTL was for. If every grant is gone, the refresh is refused and the chain ends, so a principal whose access was removed cannot keep rotating.
The group claims presented at exchange are recorded on the refresh token, because group-derived grants cannot be recomputed without them. They are claims to re-check, not permissions to reuse.
FELIX_REFRESH_TOKEN_TTL_SECONDS (default 30 days) bounds a refresh token that
is stolen and never used — one that is used produces a replay, which ends the
chain immediately.
To cut off a principal without waiting out any token's expiry, revoke its refresh tokens for the tenant; every chain it holds ends at once.
Felix includes a one-time operator bootstrap flow to initialize tenant auth before any admin tokens exist.
Why it exists:
- Admin endpoints require a Felix token with explicit management permissions.
- But Felix tokens require IdP issuer configuration and signing keys.
- Bootstrap fills that gap once per tenant, then is disabled.
- Operator enables bootstrap on the control plane:
FELIX_BOOTSTRAP_ENABLED=true-
FELIX_BOOTSTRAP_BIND_ADDR=127.0.0.1:9095(or cluster-internal address) FELIX_BOOTSTRAP_TOKEN=<random secret>- optionally
FELIX_BOOTSTRAP_TOKEN_PREVIOUS=<the token being rotated out>, accepted alongside the current one so a rotation is a rolling deploy rather than an outage - optionally
FELIX_BOOTSTRAP_TLS_CERT/FELIX_BOOTSTRAP_TLS_KEY/FELIX_BOOTSTRAP_TLS_CLIENT_CA(all three), which makes the bootstrap listener terminate mTLS and refuse, at the handshake, any client without a certificate signed by that CA
- Operator calls the internal endpoint:
POST /internal/bootstrap/tenants/{tenant_id}/initialize- header
X-Felix-Bootstrap-Token: <token>
- Control plane:
- creates the tenant if missing
- generates signing keys (EdDSA / Ed25519)
- seeds IdP issuers
- seeds RBAC policies + groupings
- marks the tenant as bootstrapped
All of it commits as one atomic store operation, exactly once per tenant:
racing the call against itself — including through different control-plane
instances behind one load balancer — produces one winner and
409for everyone else, and a failure part-way leaves the tenant retryable rather than half-initialized.
- Operator disables bootstrap after use.
The threat model, token lifetime and replay rules, the rotation procedure, and recovery steps are in security/bootstrap.md.
POST /internal/bootstrap/tenants/t1/initialize
X-Felix-Bootstrap-Token: <secret>
Content-Type: application/json
{
"display_name": "Tenant One",
"idp_issuers": [
{
"issuer": "https://login.microsoftonline.com/<tenant>/v2.0",
"audiences": ["api://felix-controlplane"],
"discovery_url": null,
"jwks_url": null,
"claim_mappings": {
"subject_claim": "sub",
"groups_claim": "groups"
}
}
],
"initial_admin_principals": ["p:alice"]
}
Auth admin permissions:
- IdP issuer admin endpoints require
tenant.manage. - RBAC policy/grouping endpoints allow either:
-
rbac.policy.manage/rbac.assignment.manageat tenant scope (tenant:{tenant_id}), or - those same actions at narrower namespace/stream/cache scopes. Bootstrap tokens never authorize normal admin endpoints.
-
- Client connects to a broker with
tenant_idand a Felix token. - Broker verifies the token using tenant JWKS fetched from:
GET /v1/tenants/{tenant_id}/.well-known/jwks.json
- Broker validates claims:
iss = felix-authaud = felix-brokerexp/nbf-
tidmatches connection tenant
- Broker parses
permsonce at connect time and enforces per operation using keyMatch2.
In the control plane store (memory or Postgres):
- Allowed OIDC issuers + audiences + claim mappings
- Casbin policies and groupings
- Tenant signing keys (current + previous for rotation)
IdP trust is configured per tenant. The control plane only accepts tokens from issuers listed for that tenant, and only for configured audiences.
IdP issuer endpoints require a Felix token with tenant.manage for the tenant.
RBAC endpoints require explicit RBAC actions (rbac.view, rbac.policy.manage, rbac.assignment.manage).
Use the control plane admin endpoints to manage IdP issuers per tenant:
POST /v1/tenants/{tenant_id}/idp-issuers
Content-Type: application/json
{
"issuer": "https://login.microsoftonline.com/<tenant>/v2.0",
"audiences": ["api://felix-controlplane"],
"discovery_url": null,
"jwks_url": null,
"claim_mappings": {
"subject_claim": "sub",
"groups_claim": "groups"
}
}
Delete an issuer:
DELETE /v1/tenants/{tenant_id}/idp-issuers/{issuer}
Insert or update the issuer in the idp_issuers table:
INSERT INTO idp_issuers (
tenant_id,
issuer,
audiences,
discovery_url,
jwks_url,
subject_claim,
groups_claim
) VALUES (
't1',
'https://login.microsoftonline.com/<tenant>/v2.0',
'["api://felix-controlplane"]'::jsonb,
NULL,
NULL,
'sub',
'groups'
)
ON CONFLICT (tenant_id, issuer) DO UPDATE SET
audiences = EXCLUDED.audiences,
discovery_url = EXCLUDED.discovery_url,
jwks_url = EXCLUDED.jwks_url,
subject_claim = EXCLUDED.subject_claim,
groups_claim = EXCLUDED.groups_claim;Notes:
- If
discovery_urlisNULL, the control plane uses{iss}/.well-known/openid-configuration. -
jwks_urlcan be set directly if your IdP doesn’t support discovery. -
subject_claimdefaults tosub.groups_claimis optional.
Use the store API to add issuers during initialization:
store
.upsert_idp_issuer(\"t1\", IdpIssuerConfig {
issuer: \"https://example-idp\".to_string(),
audiences: vec![\"felix-controlplane\".to_string()],
discovery_url: None,
jwks_url: Some(\"https://example-idp/.well-known/jwks.json\".to_string()),
claim_mappings: ClaimMappings {
subject_claim: \"sub\".to_string(),
groups_claim: Some(\"groups\".to_string()),
},
})
.await?;The token exchange endpoint will reject upstream tokens if:
- the issuer is not listed for the tenant
- the audience does not match
- the signature or standard claims are invalid
POST /v1/tenants/{tenant_id}/token/exchange
Authorization: Bearer <oidc_jwt>
Content-Type: application/json
{
"requested": ["stream.publish", "cache.read"],
"resources": ["namespace:t1/payments", "stream:t1/payments/orders/*"]
}
Response:
{
"felix_token": "<jwt>",
"expires_in": 900,
"token_type": "Bearer",
"refresh_token": "<token_id>.<secret>",
"refresh_expires_in": 2592000
}
POST /v1/tenants/{tenant_id}/token/refresh
Content-Type: application/json
{
"refresh_token": "<token_id>.<secret>"
}
No Authorization header: the refresh token is the credential.
Response:
{
"felix_token": "<jwt>",
"expires_in": 900,
"token_type": "Bearer",
"refresh_token": "<new_token_id>.<new_secret>",
"refresh_expires_in": 2592000
}
Store the replacement before using the new access token. The token you presented is already spent; losing the replacement means re-exchanging.
Every failure answers 403 with the same message — unparseable, unknown,
expired, revoked, wrong secret. Distinguishing them would let a caller probe
which token ids exist.
A wrong secret against a real token id also spends the token. Someone holding half a credential gets one guess, not unlimited ones.
GET /v1/tenants/{tenant_id}/.well-known/jwks.json
Response:
{
"keys": [
{
"kty": "OKP",
"kid": "k1",
"alg": "EdDSA",
"use": "sig",
"crv": "Ed25519",
"x": "..."
}
]
}
# policy rules (p)
# p, <role>, <tenant>, <object>, <action>
p, role:tenant-admin, tenant-a, tenant:tenant-a, tenant.manage
p, role:tenant-admin, tenant-a, tenant:tenant-a, rbac.policy.manage
p, role:payments-admin, tenant-a, namespace:tenant-a/payments, ns.manage
p, role:publisher, tenant-a, stream:tenant-a/payments/*, stream.publish
# groupings (g)
# g, <user>, <role>, <tenant>
g, p:alice, role:tenant-admin, tenant-a
g, p:bob, role:payments-admin, tenant-a
-
401 Unauthorized: invalid or missing upstream token -
403 Forbidden: tenant not allowed, issuer not allowed, or no permissions -
500 Internal Server Error: unexpected backend failures
- Policy editor and audit logging for auth decisions.
- More efficient permission compression for large policy sets.
Generated from docs/auth.md in the repository. That file is the source of truth; this page may be behind it.
Architecture & Design
- Broker-internal forwarding protocol
- Felix Architecture
- Felix Wire Protocol (v1)
- Felix semantics
- Felix – Comprehensive Design Document
Storage & Replication
- Durable Storage
- Durable Storage Performance
- Felix Durable Segment Format (v2)
- One core log, many semantics
- Plan: group-commit the cache write path
- Shard replication and leader failover
- The cache is a projection over the log
Control Plane & Cluster
- A formal model of one shard
- Control Plane + RAFT Plan (Draft)
- Metadata Raft
- Subscribe routing: redirect, not proxy
- The Postgres the control plane requires
- The local cluster harness
Clients & Configuration
- Auth Design: OIDC Exchange + Felix Tokens
- Broker Config Examples
- Client Config
- Running a client against a cluster
Security
- Bootstrap security
- Panic and abort audit — broker data path
- RBAC Model (Control Plane)
- Soak and resource-leak report — broker
- Threat model: the broker-internal surface
Performance Investigations
- Real-network performance suite
- Sharding investigation: keyed publishes, io_uring, and a ceiling that would not move
- The 0.4.0 performance matrix
- Throughput investigation: why Felix is slower than raw QUIC
Explorations & History