Guides
Writing an AuthProvider

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 returns WorkspaceMismatch (a transport-only error, see the error codes reference) if they disagree.
  • capabilities — passed straight to require_capability inside each SqliteFileSystem method. 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's actor field; 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.