mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
feat: add role-based access control (admin/member)
Adds a `role` field (admin|member) to user management: Schema: - `role TEXT NOT NULL DEFAULT 'member'` added to users table in both PostgreSQL V14 migration and libSQL schema/incremental migration - UserRecord gains `role: String` field - UserIdentity gains `role: String` field, populated from DB in DbAuthenticator and defaulting to "admin" for single-user mode Access control: - AdminUser extractor: returns 403 Forbidden if role != "admin" - /api/admin/users/* handlers: require AdminUser (create, list, detail, update, suspend, activate) - POST /api/invitations: requires AdminUser (only admins can invite) - User creation accepts optional "role" param (defaults to "member") - Invitation acceptance creates users with "member" role Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -24,6 +24,8 @@ use crate::db::Database;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserIdentity {
|
||||
pub user_id: String,
|
||||
/// `admin` or `member`.
|
||||
pub role: String,
|
||||
/// Additional user scopes this identity can read from.
|
||||
pub workspace_read_scopes: Vec<String>,
|
||||
}
|
||||
@@ -61,6 +63,7 @@ impl MultiAuthState {
|
||||
hash,
|
||||
UserIdentity {
|
||||
user_id,
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
},
|
||||
)],
|
||||
@@ -163,7 +166,8 @@ impl DbAuthenticator {
|
||||
|
||||
let identity = UserIdentity {
|
||||
user_id: user_record.id.clone(),
|
||||
workspace_read_scopes: Vec::new(), // DB-backed users don't have static scopes yet
|
||||
role: user_record.role.clone(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
};
|
||||
|
||||
// Record token usage (best-effort, don't block auth)
|
||||
@@ -230,6 +234,31 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum extractor that requires the authenticated user to have the `admin` role.
|
||||
///
|
||||
/// Use instead of `AuthenticatedUser` on endpoints that modify system-wide
|
||||
/// state (user management, model selection, extension/skill installation).
|
||||
pub struct AdminUser(pub UserIdentity);
|
||||
|
||||
impl<S> FromRequestParts<S> for AdminUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let identity = parts
|
||||
.extensions
|
||||
.get::<UserIdentity>()
|
||||
.cloned()
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?;
|
||||
if identity.role != "admin" {
|
||||
return Err((StatusCode::FORBIDDEN, "Admin role required"));
|
||||
}
|
||||
Ok(AdminUser(identity))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether query-string token auth is allowed for this request.
|
||||
///
|
||||
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
|
||||
@@ -346,6 +375,7 @@ mod tests {
|
||||
"tok-alice".to_string(),
|
||||
UserIdentity {
|
||||
user_id: "alice".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
},
|
||||
);
|
||||
@@ -353,6 +383,7 @@ mod tests {
|
||||
"tok-bob".to_string(),
|
||||
UserIdentity {
|
||||
user_id: "bob".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
},
|
||||
);
|
||||
@@ -622,6 +653,7 @@ mod tests {
|
||||
"tok-alice".to_string(),
|
||||
UserIdentity {
|
||||
user_id: "alice".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec!["shared".to_string()],
|
||||
},
|
||||
);
|
||||
@@ -629,6 +661,7 @@ mod tests {
|
||||
"tok-bob".to_string(),
|
||||
UserIdentity {
|
||||
user_id: "bob".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,14 +8,14 @@ use rand::rngs::OsRng;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::db::{InvitationRecord, UserRecord};
|
||||
|
||||
/// POST /api/invitations — create an invitation.
|
||||
/// POST /api/invitations — create an invitation (admin only).
|
||||
pub async fn invitations_create_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
AdminUser(user): AdminUser,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
@@ -170,6 +170,7 @@ pub async fn invitations_accept_handler(
|
||||
email: invitation.email.clone(),
|
||||
display_name: display_name.clone(),
|
||||
status: "active".to_string(),
|
||||
role: "member".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_login_at: None,
|
||||
|
||||
@@ -9,14 +9,14 @@ use axum::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::web::auth::AuthenticatedUser;
|
||||
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::db::UserRecord;
|
||||
|
||||
/// POST /api/admin/users — create a new user.
|
||||
pub async fn users_create_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
AdminUser(user): AdminUser,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
@@ -34,6 +34,17 @@ pub async fn users_create_handler(
|
||||
.to_string();
|
||||
|
||||
let email = body.get("email").and_then(|v| v.as_str()).map(String::from);
|
||||
let role = body
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("member")
|
||||
.to_string();
|
||||
if role != "admin" && role != "member" {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"role must be 'admin' or 'member'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = Uuid::new_v4().to_string();
|
||||
|
||||
@@ -43,6 +54,7 @@ pub async fn users_create_handler(
|
||||
email,
|
||||
display_name: display_name.clone(),
|
||||
status: "active".to_string(),
|
||||
role,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_login_at: None,
|
||||
@@ -60,6 +72,7 @@ pub async fn users_create_handler(
|
||||
"email": user_record.email,
|
||||
"display_name": user_record.display_name,
|
||||
"status": user_record.status,
|
||||
"role": user_record.role,
|
||||
"created_at": user_record.created_at.to_rfc3339(),
|
||||
"created_by": user_record.created_by,
|
||||
})))
|
||||
@@ -88,6 +101,7 @@ pub async fn users_list_handler(
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
"status": u.status,
|
||||
"role": u.role,
|
||||
"created_at": u.created_at.to_rfc3339(),
|
||||
"updated_at": u.updated_at.to_rfc3339(),
|
||||
"last_login_at": u.last_login_at.map(|dt| dt.to_rfc3339()),
|
||||
@@ -121,6 +135,7 @@ pub async fn users_detail_handler(
|
||||
"email": user_record.email,
|
||||
"display_name": user_record.display_name,
|
||||
"status": user_record.status,
|
||||
"role": user_record.role,
|
||||
"created_at": user_record.created_at.to_rfc3339(),
|
||||
"updated_at": user_record.updated_at.to_rfc3339(),
|
||||
"last_login_at": user_record.last_login_at.map(|dt| dt.to_rfc3339()),
|
||||
@@ -172,6 +187,7 @@ pub async fn users_update_handler(
|
||||
"email": updated.email,
|
||||
"display_name": updated.display_name,
|
||||
"status": updated.status,
|
||||
"role": updated.role,
|
||||
"created_at": updated.created_at.to_rfc3339(),
|
||||
"updated_at": updated.updated_at.to_rfc3339(),
|
||||
"metadata": updated.metadata,
|
||||
|
||||
@@ -3102,6 +3102,7 @@ mod tests {
|
||||
// without needing the full auth middleware layer.
|
||||
req.extensions_mut().insert(UserIdentity {
|
||||
user_id: "test".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
});
|
||||
|
||||
@@ -3186,6 +3187,7 @@ mod tests {
|
||||
// without needing the full auth middleware layer.
|
||||
req.extensions_mut().insert(UserIdentity {
|
||||
user_id: "test".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ fn two_user_auth() -> MultiAuthState {
|
||||
"tok-alice".to_string(),
|
||||
UserIdentity {
|
||||
user_id: "alice".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec!["shared".to_string()],
|
||||
},
|
||||
);
|
||||
@@ -40,6 +41,7 @@ fn two_user_auth() -> MultiAuthState {
|
||||
"tok-bob".to_string(),
|
||||
UserIdentity {
|
||||
user_id: "bob".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
||||
},
|
||||
);
|
||||
@@ -188,6 +190,7 @@ mod workspace_pool {
|
||||
);
|
||||
let identity = UserIdentity {
|
||||
user_id: "alice".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec![],
|
||||
};
|
||||
let ws = pool.get_or_create(&identity).await;
|
||||
@@ -216,6 +219,7 @@ mod workspace_pool {
|
||||
);
|
||||
let identity = UserIdentity {
|
||||
user_id: "alice".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec![],
|
||||
};
|
||||
let ws = pool.get_or_create(&identity).await;
|
||||
@@ -239,6 +243,7 @@ mod workspace_pool {
|
||||
);
|
||||
let identity = UserIdentity {
|
||||
user_id: "bob".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()],
|
||||
};
|
||||
let ws = pool.get_or_create(&identity).await;
|
||||
@@ -265,10 +270,12 @@ mod workspace_pool {
|
||||
);
|
||||
let alice_id = UserIdentity {
|
||||
user_id: "alice".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec![],
|
||||
};
|
||||
let bob_id = UserIdentity {
|
||||
user_id: "bob".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec![],
|
||||
};
|
||||
|
||||
@@ -300,6 +307,7 @@ mod workspace_pool {
|
||||
);
|
||||
let identity = UserIdentity {
|
||||
user_id: "alice".to_string(),
|
||||
role: "admin".to_string(),
|
||||
workspace_read_scopes: vec!["token-scope".to_string()],
|
||||
};
|
||||
let ws = pool.get_or_create(&identity).await;
|
||||
|
||||
Reference in New Issue
Block a user