feat(web): DB-backed auth, user/token/invitation API handlers

Adds the web gateway layer for DB-backed user management (#1605):

Auth refactor:
- CombinedAuthState wraps env-var tokens (MultiAuthState) + optional
  DbAuthenticator for DB-backed token lookup with LRU cache (60s TTL,
  1024 max entries)
- auth_middleware tries env-var tokens first, then DB fallback
- From<MultiAuthState> impl for backward compatibility
- main.rs wires with_db_auth when database is available

API handlers (12 new endpoints):
- /api/admin/users — CRUD: create, list, detail, update, suspend, activate
- /api/tokens — create (returns plaintext once), list, revoke
- /api/invitations — create, list, accept (creates user + first token)

Token creation: 32 random bytes → hex plaintext, SHA-256 hash stored.
Invitation accept: validates hash + pending + not expired, creates
user record and first API token atomically.

All test files updated for CombinedAuthState type change.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-24 14:31:02 -07:00
co-authored by Claude Opus 4.6
parent 80cee7d742
commit 27c43e185f
14 changed files with 836 additions and 39 deletions
+5 -2
View File
@@ -73,7 +73,10 @@ fn user_echo_app(auth: MultiAuthState) -> Router {
.route("/api/whoami/scopes", get(echo_user_with_scopes))
.route("/api/action", post(echo_user))
.route("/api/chat/events", get(echo_user)) // SSE endpoint (allows query token)
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.layer(middleware::from_fn_with_state(
ironclaw::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
}
// ===========================================================================
@@ -905,7 +908,7 @@ async fn start_multi_user_server_with_db() -> (
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth)
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth.into())
.await
.expect("Failed to start server with DB");
+3 -3
View File
@@ -224,7 +224,7 @@ async fn start_test_server_with_provider(
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth)
let bound_addr = start_server(addr, state.clone(), auth.into())
.await
.expect("Failed to start test server");
@@ -722,7 +722,7 @@ async fn test_no_llm_provider_returns_503() {
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state, auth).await.unwrap();
let bound_addr = start_server(addr, state, auth.into()).await.unwrap();
let url = format!("http://{}/v1/chat/completions", bound_addr);
let resp = client()
@@ -760,7 +760,7 @@ async fn test_chat_completions_body_too_large() {
post(ironclaw::channels::web::openai_compat::chat_completions_handler),
)
.route_layer(middleware::from_fn_with_state(
auth_state,
ironclaw::channels::web::auth::CombinedAuthState::from(auth_state),
ironclaw::channels::web::auth::auth_middleware,
))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
+1 -1
View File
@@ -297,7 +297,7 @@ impl GatewayWorkflowHarness {
let addr = start_server(
"127.0.0.1:0".parse().expect("valid localhost addr"),
Arc::clone(&gateway_state),
auth,
auth.into(),
)
.await
.expect("failed to start gateway server");
+1 -1
View File
@@ -72,7 +72,7 @@ async fn start_test_server() -> (
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth)
let bound_addr = start_server(addr, state.clone(), auth.into())
.await
.expect("Failed to start test server");