Skip to main content

tuitbot_server/
account.rs

1//! Account context extraction and role-based access control.
2//!
3//! Resolves the `X-Account-Id` header into an `AccountContext` with the
4//! caller's role. Missing header defaults to the backward-compatible
5//! default account.
6
7use std::sync::Arc;
8
9use axum::extract::FromRequestParts;
10use axum::http::request::Parts;
11use axum::http::StatusCode;
12use axum::response::{IntoResponse, Response};
13use serde_json::json;
14use tuitbot_core::storage::accounts::{self, DEFAULT_ACCOUNT_ID};
15
16use crate::state::AppState;
17
18/// Resolved account context available to route handlers.
19#[derive(Debug, Clone)]
20pub struct AccountContext {
21    /// The account ID (UUIDv4 or default sentinel).
22    pub account_id: String,
23    /// The caller's role on this account.
24    pub role: Role,
25}
26
27/// Role tiers for account access.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Role {
31    Admin,
32    Approver,
33    Viewer,
34}
35
36impl Role {
37    /// Whether this role can perform read operations (always true).
38    pub fn can_read(self) -> bool {
39        true
40    }
41
42    /// Whether this role can approve/reject items.
43    pub fn can_approve(self) -> bool {
44        matches!(self, Role::Admin | Role::Approver)
45    }
46
47    /// Whether this role can perform mutations (config, runtime, compose).
48    pub fn can_mutate(self) -> bool {
49        matches!(self, Role::Admin)
50    }
51}
52
53impl std::fmt::Display for Role {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Role::Admin => write!(f, "admin"),
57            Role::Approver => write!(f, "approver"),
58            Role::Viewer => write!(f, "viewer"),
59        }
60    }
61}
62
63impl std::str::FromStr for Role {
64    type Err = String;
65
66    fn from_str(s: &str) -> Result<Self, Self::Err> {
67        match s {
68            "admin" => Ok(Role::Admin),
69            "approver" => Ok(Role::Approver),
70            "viewer" => Ok(Role::Viewer),
71            other => Err(format!("unknown role: {other}")),
72        }
73    }
74}
75
76/// Error returned when account context extraction fails.
77pub struct AccountError {
78    pub status: StatusCode,
79    pub message: String,
80}
81
82impl IntoResponse for AccountError {
83    fn into_response(self) -> Response {
84        (self.status, axum::Json(json!({"error": self.message}))).into_response()
85    }
86}
87
88impl FromRequestParts<Arc<AppState>> for AccountContext {
89    type Rejection = AccountError;
90
91    /// Extract account context from the `X-Account-Id` header.
92    ///
93    /// - Missing header → default account with admin role (backward compat).
94    /// - Present header → validates account exists and resolves role.
95    fn from_request_parts(
96        parts: &mut Parts,
97        state: &Arc<AppState>,
98    ) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send {
99        let account_id = parts
100            .headers
101            .get("x-account-id")
102            .and_then(|v| v.to_str().ok())
103            .unwrap_or(DEFAULT_ACCOUNT_ID)
104            .to_string();
105
106        let db = state.db.clone();
107
108        async move {
109            // Default account always grants admin.
110            if account_id == DEFAULT_ACCOUNT_ID {
111                return Ok(AccountContext {
112                    account_id,
113                    role: Role::Admin,
114                });
115            }
116
117            // Validate account exists and is active.
118            let exists = accounts::account_exists(&db, &account_id)
119                .await
120                .map_err(|e| AccountError {
121                    status: StatusCode::INTERNAL_SERVER_ERROR,
122                    message: format!("failed to validate account: {e}"),
123                })?;
124
125            if !exists {
126                return Err(AccountError {
127                    status: StatusCode::NOT_FOUND,
128                    message: format!("account not found: {account_id}"),
129                });
130            }
131
132            // Resolve role — default actor is "dashboard" for HTTP requests.
133            let role_str = accounts::get_role(&db, &account_id, "dashboard")
134                .await
135                .map_err(|e| AccountError {
136                    status: StatusCode::INTERNAL_SERVER_ERROR,
137                    message: format!("failed to resolve role: {e}"),
138                })?;
139
140            let role = role_str
141                .as_deref()
142                .unwrap_or("viewer")
143                .parse::<Role>()
144                .unwrap_or(Role::Viewer);
145
146            Ok(AccountContext { account_id, role })
147        }
148    }
149}
150
151/// Helper to reject requests that require approval permissions.
152pub fn require_approve(ctx: &AccountContext) -> Result<(), AccountError> {
153    if ctx.role.can_approve() {
154        Ok(())
155    } else {
156        Err(AccountError {
157            status: StatusCode::FORBIDDEN,
158            message: "approver or admin role required".to_string(),
159        })
160    }
161}
162
163/// Helper to reject requests that require mutation permissions.
164pub fn require_mutate(ctx: &AccountContext) -> Result<(), AccountError> {
165    if ctx.role.can_mutate() {
166        Ok(())
167    } else {
168        Err(AccountError {
169            status: StatusCode::FORBIDDEN,
170            message: "admin role required".to_string(),
171        })
172    }
173}