tuitbot_server/
account.rs1use 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#[derive(Debug, Clone)]
20pub struct AccountContext {
21 pub account_id: String,
23 pub role: Role,
25}
26
27#[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 pub fn can_read(self) -> bool {
39 true
40 }
41
42 pub fn can_approve(self) -> bool {
44 matches!(self, Role::Admin | Role::Approver)
45 }
46
47 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
76pub 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 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 if account_id == DEFAULT_ACCOUNT_ID {
111 return Ok(AccountContext {
112 account_id,
113 role: Role::Admin,
114 });
115 }
116
117 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 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
151pub 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
163pub 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}