Skip to main content

reddb_server/auth/
middleware.rs

1//! Auth middleware helpers.
2//!
3//! Provides the [`AuthResult`] type and [`check_permission`] function used by
4//! the gRPC and HTTP layers to decide whether an incoming request is allowed.
5
6use super::{CertIdentity, OAuthIdentity, Role, UserId};
7
8// ---------------------------------------------------------------------------
9// AuthResult
10// ---------------------------------------------------------------------------
11
12/// How the caller's identity was established.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum AuthSource {
15    /// Classic password / API-key / session cookie path.
16    Password,
17    /// mTLS client certificate (Phase 3.4 PG parity).
18    ClientCert,
19    /// OAuth/OIDC Bearer token (Phase 3.4 PG parity).
20    Oauth,
21}
22
23/// Outcome of auth validation for an incoming request.
24#[derive(Debug, Clone)]
25pub enum AuthResult {
26    /// Fully authenticated with RBAC.
27    Authenticated {
28        principal: UserId,
29        role: Role,
30        /// Which auth path produced this identity. Defaults to
31        /// `Password` for callers that haven't been updated to set it
32        /// explicitly, keeping backwards compatibility.
33        source: AuthSource,
34    },
35    /// No credentials provided.
36    Anonymous,
37    /// Credentials were provided but rejected.
38    Denied(String),
39}
40
41impl AuthResult {
42    /// Back-compat constructor for password auth — callers that predate
43    /// the `AuthSource` field can keep passing `(user, role)`.
44    pub fn password(username: impl Into<String>, role: Role) -> Self {
45        Self::Authenticated {
46            principal: UserId::platform(username),
47            role,
48            source: AuthSource::Password,
49        }
50    }
51
52    pub fn password_principal(principal: UserId, role: Role) -> Self {
53        Self::Authenticated {
54            principal,
55            role,
56            source: AuthSource::Password,
57        }
58    }
59
60    /// Build an `AuthResult` from a validated client certificate.
61    pub fn from_cert(id: CertIdentity) -> Self {
62        Self::Authenticated {
63            principal: UserId::platform(id.username),
64            role: id.role,
65            source: AuthSource::ClientCert,
66        }
67    }
68
69    /// Build an `AuthResult` from a validated OAuth/OIDC token.
70    pub fn from_oauth(id: OAuthIdentity) -> Self {
71        Self::Authenticated {
72            principal: UserId::from_parts(id.tenant.as_deref(), &id.username),
73            role: id.role,
74            source: AuthSource::Oauth,
75        }
76    }
77
78    /// Short description suitable for logging.
79    pub fn summary(&self) -> String {
80        match self {
81            Self::Authenticated {
82                principal,
83                role,
84                source,
85            } => {
86                let src = match source {
87                    AuthSource::Password => "pwd",
88                    AuthSource::ClientCert => "cert",
89                    AuthSource::Oauth => "oauth",
90                };
91                format!("user={principal} role={role} via={src}")
92            }
93            Self::Anonymous => "anonymous".to_string(),
94            Self::Denied(reason) => format!("denied: {reason}"),
95        }
96    }
97
98    /// Whether this result represents a successfully identified caller.
99    pub fn is_authenticated(&self) -> bool {
100        matches!(self, Self::Authenticated { .. })
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Permission check
106// ---------------------------------------------------------------------------
107
108/// Check whether the given [`AuthResult`] has sufficient privileges.
109///
110/// * `requires_write` -- the operation mutates data.
111/// * `requires_admin` -- the operation requires admin privileges (user
112///   management, index ops, etc.).
113pub fn check_permission(
114    auth: &AuthResult,
115    requires_write: bool,
116    requires_admin: bool,
117) -> Result<(), String> {
118    match auth {
119        AuthResult::Authenticated { role, .. } => {
120            if requires_admin && !role.can_admin() {
121                return Err("admin role required".into());
122            }
123            if requires_write && !role.can_write() {
124                return Err("write permission required".into());
125            }
126            Ok(())
127        }
128        AuthResult::Anonymous => {
129            if requires_admin {
130                return Err("admin authentication required".into());
131            }
132            // When auth is disabled, anonymous can read AND write
133            Ok(())
134        }
135        AuthResult::Denied(reason) => Err(reason.clone()),
136    }
137}
138
139// ---------------------------------------------------------------------------
140// Tests
141// ---------------------------------------------------------------------------
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_admin_can_do_everything() {
149        let auth = AuthResult::Authenticated {
150            principal: UserId::platform("root"),
151            role: Role::Admin,
152            source: AuthSource::Password,
153        };
154        assert!(check_permission(&auth, false, false).is_ok());
155        assert!(check_permission(&auth, true, false).is_ok());
156        assert!(check_permission(&auth, false, true).is_ok());
157        assert!(check_permission(&auth, true, true).is_ok());
158    }
159
160    #[test]
161    fn test_write_role_cannot_admin() {
162        let auth = AuthResult::Authenticated {
163            principal: UserId::platform("writer"),
164            role: Role::Write,
165            source: AuthSource::Password,
166        };
167        assert!(check_permission(&auth, false, false).is_ok());
168        assert!(check_permission(&auth, true, false).is_ok());
169        assert!(check_permission(&auth, false, true).is_err());
170    }
171
172    #[test]
173    fn test_read_role_cannot_write() {
174        let auth = AuthResult::Authenticated {
175            principal: UserId::platform("reader"),
176            role: Role::Read,
177            source: AuthSource::Password,
178        };
179        assert!(check_permission(&auth, false, false).is_ok());
180        assert!(check_permission(&auth, true, false).is_err());
181        assert!(check_permission(&auth, false, true).is_err());
182    }
183
184    #[test]
185    fn test_anonymous_access() {
186        let auth = AuthResult::Anonymous;
187        // When auth disabled: anonymous can read and write, but not admin
188        assert!(check_permission(&auth, false, false).is_ok());
189        assert!(check_permission(&auth, true, false).is_ok());
190        assert!(check_permission(&auth, false, true).is_err());
191    }
192
193    #[test]
194    fn test_denied_always_fails() {
195        let auth = AuthResult::Denied("bad token".into());
196        assert!(check_permission(&auth, false, false).is_err());
197        assert!(check_permission(&auth, true, true).is_err());
198    }
199
200    #[test]
201    fn test_auth_result_summary() {
202        let auth = AuthResult::Authenticated {
203            principal: UserId::platform("alice"),
204            role: Role::Admin,
205            source: AuthSource::Password,
206        };
207        assert!(auth.summary().contains("alice"));
208        assert!(auth.is_authenticated());
209
210        let anon = AuthResult::Anonymous;
211        assert_eq!(anon.summary(), "anonymous");
212        assert!(!anon.is_authenticated());
213    }
214
215    #[test]
216    fn oauth_result_preserves_tenant_principal() {
217        let auth = AuthResult::from_oauth(OAuthIdentity {
218            username: "alice".to_string(),
219            tenant: Some("acme".to_string()),
220            role: Role::Read,
221            issuer: "https://issuer.example".to_string(),
222            subject: None,
223            expires_at_unix_secs: None,
224        });
225        let AuthResult::Authenticated { principal, .. } = auth else {
226            panic!("expected authenticated result");
227        };
228        assert_eq!(principal, UserId::scoped("acme", "alice"));
229    }
230}