Writing an AuthProvider
fslite-server is a pluggable shell over an AuthProvider. The HTTP
adapter knows how to call AuthProvider::authenticate, how to enforce
that the resolved WorkspaceId matches the URL, how to map a denial
into a 401, and how to forward actor_metadata into the change feed.
Everything else — token formats, lookups, expiry, refresh — is yours.
The trait
The contract (verbatim from
auth.rs (opens in a new tab)):
#[async_trait]
pub trait AuthProvider: Send + Sync {
async fn authenticate(
&self,
headers: &HeaderMap,
) -> Result<AuthenticatedActor, ApiError>;
}You get the full inbound HeaderMap — pull out Authorization,
forwarded X-* headers, the IP, or anything else you need. The
returned AuthenticatedActor carries the resolved identity and a
BTreeSet<Capability> the server uses for the request.
The actor
pub struct AuthenticatedActor {
pub workspace_id: WorkspaceId,
pub capabilities: BTreeSet<Capability>,
pub actor_metadata: BTreeMap<String, serde_json::Value>,
}workspace_id— the only workspace this actor may address in this request. The server compares it against the URL path and returnsWorkspaceMismatch(a transport-only error, see the error codes reference) if they disagree.capabilities— passed straight torequire_capabilityinside eachSqliteFileSystemmethod. See the Embedding guide for the exact method-to-capability mapping.actor_metadata— opaque key/value map. Today this is just carried in the change-feed row'sactorfield; build it however makes sense for your audit story.
A token-minting AuthProvider
The shipped fslite-server main.rs
(crates/fslite-server/src/main.rs (opens in a new tab))
uses the reference BearerTokenAuthProvider from auth.rs, which
reads FSLITE_TOKENS once at startup and never mints a new token.
For any real deployment you'll want to mint at runtime. The shape:
use std::collections::HashMap;
use std::sync::RwLock;
use axum::http::HeaderMap;
use uuid::Uuid;
pub struct MintableTokenAuthProvider {
tokens: RwLock<HashMap<String, (WorkspaceId, BTreeSet<Capability>)>>,
}
impl MintableTokenAuthProvider {
pub fn new() -> Self {
Self { tokens: RwLock::new(HashMap::new()) }
}
/// Mint a fresh opaque token tied to the given workspace and
/// capability set. Returned once to the caller; the server keeps
/// only the `(token → workspace, capabilities)` mapping.
pub fn mint(
&self,
workspace_id: WorkspaceId,
capabilities: BTreeSet<Capability>,
) -> String {
let token = Uuid::now_v7().simple().to_string();
self.tokens
.write()
.unwrap()
.insert(token.clone(), (workspace_id, capabilities));
token
}
}
#[async_trait]
impl AuthProvider for MintableTokenAuthProvider {
async fn authenticate(
&self,
headers: &HeaderMap,
) -> Result<AuthenticatedActor, ApiError> {
let raw = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(ApiError::Unauthenticated("no Authorization header".into()))?;
let token = raw
.strip_prefix("Bearer ")
.ok_or(ApiError::Unauthenticated("expected Bearer scheme".into()))?;
let (workspace_id, capabilities) =
self.tokens.read().unwrap().get(token).cloned()
.ok_or(ApiError::Unauthenticated("unknown token".into()))?;
Ok(AuthenticatedActor {
workspace_id,
capabilities,
actor_metadata: BTreeMap::new(),
})
}
}This isn't a runnable example — it's the pattern. To exercise it end
to end you'd want to wrap a HashMap revocation check, an expiry, an
audit log, etc., but the integration shape (AuthProvider
implementation + mint call from your POST /v1/workspaces handler)
stays the same.
Injecting actor metadata from headers
The actor_metadata field is the natural seam for projecting audit
information into the change feed:
let mut actor_metadata = BTreeMap::new();
if let Some(ip) = headers.get("x-forwarded-for") {
if let Ok(s) = ip.to_str() {
actor_metadata.insert(
"client_ip".into(),
serde_json::Value::String(s.to_string()),
);
}
}
// … same for any other header you care about
Ok(AuthenticatedActor {
workspace_id, capabilities, actor_metadata,
})Whatever you put here shows up in Change::actor_metadata for any
mutating operation this actor performs, and is therefore preserved
forever in the workspace's change feed.
Errors are uniform
Denials from your AuthProvider should always return either
ApiError::Unauthenticated(String) (for missing / unrecognized
credentials) or a Domain(FsError::new(PermissionDenied, ...)) (when
the credentials are valid but the actor lacks a specific capability).
The first maps to HTTP 401; the second maps to 403. See the
error codes reference for the full
mapping.