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}