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};
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        username: String,
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            username: username.into(),
47            role,
48            source: AuthSource::Password,
49        }
50    }
51
52    /// Build an `AuthResult` from a validated client certificate.
53    pub fn from_cert(id: CertIdentity) -> Self {
54        Self::Authenticated {
55            username: id.username,
56            role: id.role,
57            source: AuthSource::ClientCert,
58        }
59    }
60
61    /// Build an `AuthResult` from a validated OAuth/OIDC token.
62    pub fn from_oauth(id: OAuthIdentity) -> Self {
63        Self::Authenticated {
64            username: id.username,
65            role: id.role,
66            source: AuthSource::Oauth,
67        }
68    }
69
70    /// Short description suitable for logging.
71    pub fn summary(&self) -> String {
72        match self {
73            Self::Authenticated {
74                username,
75                role,
76                source,
77            } => {
78                let src = match source {
79                    AuthSource::Password => "pwd",
80                    AuthSource::ClientCert => "cert",
81                    AuthSource::Oauth => "oauth",
82                };
83                format!("user={username} role={role} via={src}")
84            }
85            Self::Anonymous => "anonymous".to_string(),
86            Self::Denied(reason) => format!("denied: {reason}"),
87        }
88    }
89
90    /// Whether this result represents a successfully identified caller.
91    pub fn is_authenticated(&self) -> bool {
92        matches!(self, Self::Authenticated { .. })
93    }
94}
95
96// ---------------------------------------------------------------------------
97// Permission check
98// ---------------------------------------------------------------------------
99
100/// Check whether the given [`AuthResult`] has sufficient privileges.
101///
102/// * `requires_write` -- the operation mutates data.
103/// * `requires_admin` -- the operation requires admin privileges (user
104///   management, index ops, etc.).
105pub fn check_permission(
106    auth: &AuthResult,
107    requires_write: bool,
108    requires_admin: bool,
109) -> Result<(), String> {
110    match auth {
111        AuthResult::Authenticated { role, .. } => {
112            if requires_admin && !role.can_admin() {
113                return Err("admin role required".into());
114            }
115            if requires_write && !role.can_write() {
116                return Err("write permission required".into());
117            }
118            Ok(())
119        }
120        AuthResult::Anonymous => {
121            if requires_admin {
122                return Err("admin authentication required".into());
123            }
124            // When auth is disabled, anonymous can read AND write
125            Ok(())
126        }
127        AuthResult::Denied(reason) => Err(reason.clone()),
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Tests
133// ---------------------------------------------------------------------------
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_admin_can_do_everything() {
141        let auth = AuthResult::Authenticated {
142            username: "root".into(),
143            role: Role::Admin,
144            source: AuthSource::Password,
145        };
146        assert!(check_permission(&auth, false, false).is_ok());
147        assert!(check_permission(&auth, true, false).is_ok());
148        assert!(check_permission(&auth, false, true).is_ok());
149        assert!(check_permission(&auth, true, true).is_ok());
150    }
151
152    #[test]
153    fn test_write_role_cannot_admin() {
154        let auth = AuthResult::Authenticated {
155            username: "writer".into(),
156            role: Role::Write,
157            source: AuthSource::Password,
158        };
159        assert!(check_permission(&auth, false, false).is_ok());
160        assert!(check_permission(&auth, true, false).is_ok());
161        assert!(check_permission(&auth, false, true).is_err());
162    }
163
164    #[test]
165    fn test_read_role_cannot_write() {
166        let auth = AuthResult::Authenticated {
167            username: "reader".into(),
168            role: Role::Read,
169            source: AuthSource::Password,
170        };
171        assert!(check_permission(&auth, false, false).is_ok());
172        assert!(check_permission(&auth, true, false).is_err());
173        assert!(check_permission(&auth, false, true).is_err());
174    }
175
176    #[test]
177    fn test_anonymous_access() {
178        let auth = AuthResult::Anonymous;
179        // When auth disabled: anonymous can read and write, but not admin
180        assert!(check_permission(&auth, false, false).is_ok());
181        assert!(check_permission(&auth, true, false).is_ok());
182        assert!(check_permission(&auth, false, true).is_err());
183    }
184
185    #[test]
186    fn test_denied_always_fails() {
187        let auth = AuthResult::Denied("bad token".into());
188        assert!(check_permission(&auth, false, false).is_err());
189        assert!(check_permission(&auth, true, true).is_err());
190    }
191
192    #[test]
193    fn test_auth_result_summary() {
194        let auth = AuthResult::Authenticated {
195            username: "alice".into(),
196            role: Role::Admin,
197            source: AuthSource::Password,
198        };
199        assert!(auth.summary().contains("alice"));
200        assert!(auth.is_authenticated());
201
202        let anon = AuthResult::Anonymous;
203        assert_eq!(anon.summary(), "anonymous");
204        assert!(!anon.is_authenticated());
205    }
206}