Skip to main content

allowthem_saas/manage/
roles.rs

1use axum::extract::{Extension, Path};
2use axum::http::StatusCode;
3use axum::response::Json;
4use serde::{Deserialize, Serialize};
5
6use allowthem_core::AllowThem;
7use allowthem_core::error::AuthError;
8use allowthem_core::types::{RoleId, RoleName};
9
10use super::{AdminKey, ManageError, ManageState};
11
12#[derive(Serialize)]
13pub struct RoleResponse {
14    pub id: String,
15    pub name: String,
16    pub description: Option<String>,
17    pub created_at: String,
18}
19
20impl RoleResponse {
21    fn from_role(r: &allowthem_core::types::Role) -> Self {
22        Self {
23            id: r.id.to_string(),
24            name: r.name.as_str().to_owned(),
25            description: r.description.clone(),
26            created_at: r.created_at.to_rfc3339(),
27        }
28    }
29}
30
31#[derive(Deserialize)]
32pub struct CreateRoleRequest {
33    name: String,
34    description: Option<String>,
35}
36
37#[derive(Deserialize)]
38pub struct PatchRoleRequest {
39    name: Option<String>,
40    description: Option<String>,
41}
42
43fn map_auth_error(e: AuthError) -> ManageError {
44    match e {
45        AuthError::NotFound => ManageError::NotFound,
46        AuthError::Conflict(_) => ManageError::Conflict,
47        _ => ManageError::Internal(e.to_string()),
48    }
49}
50
51fn parse_role_id(s: &str) -> Result<RoleId, ManageError> {
52    s.parse().map_err(|_| ManageError::NotFound)
53}
54
55async fn list(
56    Extension(ath): Extension<AllowThem>,
57    _admin: AdminKey,
58) -> Result<Json<Vec<RoleResponse>>, ManageError> {
59    let roles = ath.db().list_roles().await.map_err(map_auth_error)?;
60    Ok(Json(roles.iter().map(RoleResponse::from_role).collect()))
61}
62
63async fn create(
64    Extension(ath): Extension<AllowThem>,
65    _admin: AdminKey,
66    Json(req): Json<CreateRoleRequest>,
67) -> Result<(StatusCode, Json<RoleResponse>), ManageError> {
68    let name = RoleName::new(&req.name);
69    let role = ath
70        .db()
71        .create_role(&name, req.description.as_deref())
72        .await
73        .map_err(map_auth_error)?;
74    Ok((StatusCode::CREATED, Json(RoleResponse::from_role(&role))))
75}
76
77async fn update(
78    Extension(ath): Extension<AllowThem>,
79    _admin: AdminKey,
80    Path(id): Path<String>,
81    Json(req): Json<PatchRoleRequest>,
82) -> Result<Json<RoleResponse>, ManageError> {
83    let rid = parse_role_id(&id)?;
84
85    let current = ath
86        .db()
87        .get_role(&rid)
88        .await
89        .map_err(map_auth_error)?
90        .ok_or(ManageError::NotFound)?;
91
92    let new_name = req
93        .name
94        .as_deref()
95        .map(RoleName::new)
96        .unwrap_or_else(|| current.name.clone());
97    let new_description = match req.description {
98        Some(ref d) => Some(d.as_str()),
99        None => current.description.as_deref(),
100    };
101
102    let role = ath
103        .db()
104        .update_role(&rid, &new_name, new_description)
105        .await
106        .map_err(map_auth_error)?;
107    Ok(Json(RoleResponse::from_role(&role)))
108}
109
110async fn delete_role(
111    Extension(ath): Extension<AllowThem>,
112    _admin: AdminKey,
113    Path(id): Path<String>,
114) -> Result<StatusCode, ManageError> {
115    let rid = parse_role_id(&id)?;
116    let deleted = ath.db().delete_role(&rid).await.map_err(map_auth_error)?;
117    if deleted {
118        Ok(StatusCode::NO_CONTENT)
119    } else {
120        Err(ManageError::NotFound)
121    }
122}
123
124pub fn role_routes() -> axum::Router<ManageState> {
125    use axum::routing::{get, patch};
126    axum::Router::new()
127        .route("/", get(list).post(create))
128        .route("/{id}", patch(update).delete(delete_role))
129}
130
131#[cfg(test)]
132mod tests {
133    use std::path::PathBuf;
134    use std::sync::Arc;
135
136    use axum::Router;
137    use axum::body::{Body, to_bytes};
138    use axum::http::{Request, StatusCode};
139    use chrono::Utc;
140    use tower::ServiceExt;
141    use uuid::Uuid;
142
143    use allowthem_core::{AllowThem, AllowThemBuilder};
144
145    use super::*;
146    use crate::api_keys::{ApiKey, ApiKeyId, ApiKeyScope};
147    use crate::cache::HandleCache;
148    use crate::control_db::ControlDb;
149    use crate::manage::ManageState;
150    use crate::tenants::{TenantBuilderConfig, TenantId};
151
152    async fn make_handle() -> AllowThem {
153        AllowThemBuilder::new("sqlite::memory:")
154            .cookie_secure(false)
155            .build()
156            .await
157            .unwrap()
158    }
159
160    async fn make_state() -> ManageState {
161        use std::str::FromStr;
162        let opts = sqlx::sqlite::SqliteConnectOptions::from_str("sqlite::memory:")
163            .unwrap()
164            .pragma("foreign_keys", "ON");
165        let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
166        let ctrl = Arc::new(ControlDb::new(pool).await.unwrap());
167        ManageState::new(
168            ctrl,
169            HandleCache::new(1),
170            PathBuf::from("/tmp"),
171            Arc::new(TenantBuilderConfig {
172                mfa_key: [0u8; 32],
173                signing_key: [0u8; 32],
174                csrf_key: [0u8; 32],
175                base_domain: "test.example.com".into(),
176                is_production: false,
177                email_sender: None,
178                event_sink: None,
179                event_sink_factory: None,
180                mau_sink: None,
181                email_sender_factory: None,
182            }),
183            1000,
184            Arc::new(crate::dns::MockDnsResolver::new()),
185        )
186    }
187
188    fn make_admin_key() -> ApiKey {
189        ApiKey {
190            id: ApiKeyId::from_uuid(Uuid::nil()),
191            tenant_id: TenantId::from(Uuid::nil()),
192            name: "test".into(),
193            scope: vec![ApiKeyScope::Admin],
194            created_at: Utc::now(),
195            expires_at: None,
196            last_used_at: None,
197        }
198    }
199
200    async fn test_router(handle: AllowThem) -> Router {
201        let state = make_state().await;
202        Router::<ManageState>::new()
203            .merge(role_routes())
204            .layer(axum::Extension(handle))
205            .layer(axum::Extension(make_admin_key()))
206            .with_state(state)
207    }
208
209    #[tokio::test]
210    async fn list_roles_returns_empty() {
211        let h = make_handle().await;
212        let app = test_router(h).await;
213        let req = Request::get("/").body(Body::empty()).unwrap();
214        let resp = app.oneshot(req).await.unwrap();
215        assert_eq!(resp.status(), StatusCode::OK);
216        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
217        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
218        assert_eq!(json.as_array().unwrap().len(), 0);
219    }
220
221    #[tokio::test]
222    async fn create_role_returns_201() {
223        let h = make_handle().await;
224        let app = test_router(h).await;
225        let body = serde_json::json!({ "name": "editor" }).to_string();
226        let req = Request::post("/")
227            .header("content-type", "application/json")
228            .body(Body::from(body))
229            .unwrap();
230        let resp = app.oneshot(req).await.unwrap();
231        assert_eq!(resp.status(), StatusCode::CREATED);
232        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
233        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
234        assert_eq!(json["name"], "editor");
235    }
236
237    #[tokio::test]
238    async fn create_role_conflict_returns_409() {
239        let h = make_handle().await;
240        let name = allowthem_core::types::RoleName::new("viewer");
241        h.db().create_role(&name, None).await.unwrap();
242        let app = test_router(h).await;
243        let body = serde_json::json!({ "name": "viewer" }).to_string();
244        let req = Request::post("/")
245            .header("content-type", "application/json")
246            .body(Body::from(body))
247            .unwrap();
248        let resp = app.oneshot(req).await.unwrap();
249        assert_eq!(resp.status(), StatusCode::CONFLICT);
250    }
251
252    #[tokio::test]
253    async fn patch_role_updates_name() {
254        let h = make_handle().await;
255        let name = allowthem_core::types::RoleName::new("old");
256        let role = h.db().create_role(&name, None).await.unwrap();
257        let app = test_router(h).await;
258        let body = serde_json::json!({ "name": "new" }).to_string();
259        let req = Request::patch(format!("/{}", role.id))
260            .header("content-type", "application/json")
261            .body(Body::from(body))
262            .unwrap();
263        let resp = app.oneshot(req).await.unwrap();
264        assert_eq!(resp.status(), StatusCode::OK);
265        let body = to_bytes(resp.into_body(), 4096).await.unwrap();
266        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
267        assert_eq!(json["name"], "new");
268    }
269
270    #[tokio::test]
271    async fn delete_role_returns_204() {
272        let h = make_handle().await;
273        let name = allowthem_core::types::RoleName::new("todelete");
274        let role = h.db().create_role(&name, None).await.unwrap();
275        let app = test_router(h).await;
276        let req = Request::delete(format!("/{}", role.id))
277            .body(Body::empty())
278            .unwrap();
279        let resp = app.oneshot(req).await.unwrap();
280        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
281    }
282
283    #[tokio::test]
284    async fn delete_role_not_found_returns_404() {
285        let h = make_handle().await;
286        let app = test_router(h).await;
287        let req = Request::delete(format!("/{}", Uuid::nil()))
288            .body(Body::empty())
289            .unwrap();
290        let resp = app.oneshot(req).await.unwrap();
291        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
292    }
293}