Skip to main content

vectorizer_sdk/client/
auth.rs

1//! Authentication surface.
2//!
3//! Covers the `/auth/*` REST endpoints: current-user info, logout,
4//! token refresh, password validation, API key management (CRUD), user
5//! management (admin), and the phase15 admin endpoints: key rotation,
6//! scoped key creation, token introspection, and audit-log query.
7//!
8//! All write operations that require `Role::Admin` are marked in
9//! their doc comments. Calling them without the right role will
10//! return a [`VectorizerError`] wrapping the server's 403 response.
11
12use super::VectorizerClient;
13use crate::error::{Result, VectorizerError};
14use crate::models::{
15    ApiKey, ApiKeyUsageReport, ApiKeyView, AuditEntry, AuditQuery, CreateApiKeyRequest,
16    CreateScopedApiKeyRequest, CreateUserRequest, JwtToken, PasswordPolicyReport, RotatedKey,
17    TokenIntrospection, UpdateApiKeyPermissionsRequest, User,
18};
19
20impl VectorizerClient {
21    /// Return the authenticated user's claims.
22    ///
23    /// Calls `GET /auth/me`. Requires a valid JWT / API key on the
24    /// configured transport.
25    pub async fn me(&self) -> Result<User> {
26        let response = self.make_request("GET", "/auth/me", None).await?;
27        serde_json::from_str(&response)
28            .map_err(|e| VectorizerError::server(format!("Failed to parse me response: {e}")))
29    }
30
31    /// Invalidate the current session token.
32    ///
33    /// Calls `POST /auth/logout`. The token is blacklisted until its
34    /// natural expiry.
35    pub async fn logout(&self) -> Result<()> {
36        self.make_request("POST", "/auth/logout", None).await?;
37        Ok(())
38    }
39
40    /// Exchange the current token for a fresh one with an extended TTL.
41    ///
42    /// Calls `POST /auth/refresh`.
43    pub async fn refresh_token(&self) -> Result<JwtToken> {
44        let response = self
45            .make_request("POST", "/auth/refresh", Some(serde_json::json!({})))
46            .await?;
47        serde_json::from_str(&response).map_err(|e| {
48            VectorizerError::server(format!("Failed to parse refresh_token response: {e}"))
49        })
50    }
51
52    /// Validate a password against the server's password policy without
53    /// creating an account.
54    ///
55    /// Calls `POST /auth/validate-password` with `{password}`.
56    pub async fn validate_password(&self, password: &str) -> Result<PasswordPolicyReport> {
57        let payload = serde_json::json!({ "password": password });
58        let response = self
59            .make_request("POST", "/auth/validate-password", Some(payload))
60            .await?;
61        serde_json::from_str(&response).map_err(|e| {
62            VectorizerError::server(format!("Failed to parse validate_password response: {e}"))
63        })
64    }
65
66    /// Create a new API key for the calling user.
67    ///
68    /// Calls `POST /auth/keys`. The `api_key` field in the returned
69    /// [`ApiKey`] is only present at creation time — store it securely.
70    pub async fn create_api_key(&self, request: CreateApiKeyRequest) -> Result<ApiKey> {
71        let payload = serde_json::to_value(&request).map_err(|e| {
72            VectorizerError::server(format!("Failed to serialize create_api_key request: {e}"))
73        })?;
74        let response = self
75            .make_request("POST", "/auth/keys", Some(payload))
76            .await?;
77        serde_json::from_str(&response).map_err(|e| {
78            VectorizerError::server(format!("Failed to parse create_api_key response: {e}"))
79        })
80    }
81
82    /// List the API keys belonging to the calling user.
83    ///
84    /// Calls `GET /auth/keys`. The `api_key` field is omitted in
85    /// list responses for security.
86    pub async fn list_api_keys(&self) -> Result<Vec<ApiKey>> {
87        let response = self.make_request("GET", "/auth/keys", None).await?;
88        let val: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
89            VectorizerError::server(format!("Failed to parse list_api_keys response: {e}"))
90        })?;
91        let arr = val
92            .get("keys")
93            .and_then(|k| k.as_array())
94            .cloned()
95            .unwrap_or_default();
96        arr.into_iter()
97            .map(|v| {
98                serde_json::from_value(v).map_err(|e| {
99                    VectorizerError::server(format!("Failed to parse api key entry: {e}"))
100                })
101            })
102            .collect()
103    }
104
105    /// Revoke an API key by id.
106    ///
107    /// Calls `DELETE /auth/keys/{id}`. Admin can revoke any key;
108    /// regular users can only revoke their own.
109    pub async fn revoke_api_key(&self, id: &str) -> Result<()> {
110        self.make_request("DELETE", &format!("/auth/keys/{id}"), None)
111            .await?;
112        Ok(())
113    }
114
115    /// Create a new user (admin only).
116    ///
117    /// Calls `POST /auth/users`. Requires `Role::Admin`.
118    pub async fn create_user(&self, request: CreateUserRequest) -> Result<User> {
119        let payload = serde_json::to_value(&request).map_err(|e| {
120            VectorizerError::server(format!("Failed to serialize create_user request: {e}"))
121        })?;
122        let response = self
123            .make_request("POST", "/auth/users", Some(payload))
124            .await?;
125        // Server returns {user_id, username, roles, message}
126        serde_json::from_str(&response).map_err(|e| {
127            VectorizerError::server(format!("Failed to parse create_user response: {e}"))
128        })
129    }
130
131    /// List all users (admin only).
132    ///
133    /// Calls `GET /auth/users`. Requires `Role::Admin`.
134    pub async fn list_users(&self) -> Result<Vec<User>> {
135        let response = self.make_request("GET", "/auth/users", None).await?;
136        let val: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
137            VectorizerError::server(format!("Failed to parse list_users response: {e}"))
138        })?;
139        let arr = val
140            .get("users")
141            .and_then(|u| u.as_array())
142            .cloned()
143            .unwrap_or_default();
144        arr.into_iter()
145            .map(|v| {
146                serde_json::from_value(v).map_err(|e| {
147                    VectorizerError::server(format!("Failed to parse user entry: {e}"))
148                })
149            })
150            .collect()
151    }
152
153    /// Delete a user (admin only).
154    ///
155    /// Calls `DELETE /auth/users/{username}`. Requires `Role::Admin`.
156    /// The server refuses to delete self or the last admin.
157    pub async fn delete_user(&self, username: &str) -> Result<()> {
158        self.make_request("DELETE", &format!("/auth/users/{username}"), None)
159            .await?;
160        Ok(())
161    }
162
163    /// Change a user's password.
164    ///
165    /// Calls `PUT /auth/users/{username}/password` with
166    /// `{new_password}`. Admins can change any password; non-admins
167    /// must also supply `current_password`.
168    pub async fn change_password(&self, username: &str, new_password: &str) -> Result<()> {
169        let payload = serde_json::json!({ "new_password": new_password });
170        self.make_request(
171            "PUT",
172            &format!("/auth/users/{username}/password"),
173            Some(payload),
174        )
175        .await?;
176        Ok(())
177    }
178
179    // ── phase15 admin endpoints ───────────────────────────────────────────────
180
181    /// Atomically rotate an API key (admin only).
182    ///
183    /// Calls `POST /auth/keys/{id}/rotate` with an empty body.
184    /// Returns both the old and new tokens plus a grace window during which
185    /// the old token remains valid.
186    pub async fn rotate_api_key(&self, id: &str) -> Result<RotatedKey> {
187        let response = self
188            .make_request(
189                "POST",
190                &format!("/auth/keys/{id}/rotate"),
191                Some(serde_json::json!({})),
192            )
193            .await?;
194        serde_json::from_str(&response).map_err(|e| {
195            VectorizerError::server(format!("Failed to parse rotate_api_key response: {e}"))
196        })
197    }
198
199    /// Create an API key with optional per-collection scopes.
200    ///
201    /// Calls `POST /auth/keys`. When `scopes` is non-empty the key is
202    /// restricted to the listed collections. When empty the key is
203    /// default-deny on scope-enforced routes.
204    pub async fn create_scoped_api_key(
205        &self,
206        request: CreateScopedApiKeyRequest,
207    ) -> Result<ApiKey> {
208        let payload = serde_json::to_value(&request).map_err(|e| {
209            VectorizerError::server(format!(
210                "Failed to serialize create_scoped_api_key request: {e}"
211            ))
212        })?;
213        let response = self
214            .make_request("POST", "/auth/keys", Some(payload))
215            .await?;
216        serde_json::from_str(&response).map_err(|e| {
217            VectorizerError::server(format!(
218                "Failed to parse create_scoped_api_key response: {e}"
219            ))
220        })
221    }
222
223    /// Replace `permissions` (and optionally `scopes`) on an existing
224    /// API key without rotating the credential. Admin-only.
225    ///
226    /// Calls `PUT /auth/keys/{id}/permissions`. Returns the updated
227    /// key view with the live `usage_count` stamped in.
228    ///
229    /// `key_hash`, `id`, `user_id`, and `created_at` are immutable —
230    /// rotate the key with `rotate_api_key` if those need to change.
231    pub async fn update_api_key_permissions(
232        &self,
233        id: &str,
234        request: UpdateApiKeyPermissionsRequest,
235    ) -> Result<ApiKeyView> {
236        let payload = serde_json::to_value(&request).map_err(|e| {
237            VectorizerError::server(format!(
238                "Failed to serialize update_api_key_permissions request: {e}"
239            ))
240        })?;
241        let response = self
242            .make_request(
243                "PUT",
244                &format!("/auth/keys/{id}/permissions"),
245                Some(payload),
246            )
247            .await?;
248        serde_json::from_str(&response).map_err(|e| {
249            VectorizerError::server(format!(
250                "Failed to parse update_api_key_permissions response: {e}"
251            ))
252        })
253    }
254
255    /// Fetch the per-day usage time-series for an API key. Admin-only.
256    ///
257    /// Calls `GET /auth/keys/{id}/usage?window={days}`. `days` is
258    /// clamped server-side to 1..=30; `None` defaults to 7. The
259    /// response carries the live key view, the bucket array (oldest
260    /// first, including zero-count days), and the window total.
261    pub async fn get_api_key_usage(
262        &self,
263        id: &str,
264        window_days: Option<usize>,
265    ) -> Result<ApiKeyUsageReport> {
266        let path = match window_days {
267            Some(n) => format!("/auth/keys/{id}/usage?window={n}"),
268            None => format!("/auth/keys/{id}/usage"),
269        };
270        let response = self.make_request("GET", &path, None).await?;
271        serde_json::from_str(&response).map_err(|e| {
272            VectorizerError::server(format!("Failed to parse get_api_key_usage response: {e}"))
273        })
274    }
275
276    /// Introspect a token — RFC 7662.
277    ///
278    /// Calls `POST /auth/introspect` with `{token}`. Requires authentication
279    /// but NOT admin. Returns `active: false` for any unrecognized token.
280    pub async fn introspect_token(&self, token: &str) -> Result<TokenIntrospection> {
281        let payload = serde_json::json!({ "token": token });
282        let response = self
283            .make_request("POST", "/auth/introspect", Some(payload))
284            .await?;
285        serde_json::from_str(&response).map_err(|e| {
286            VectorizerError::server(format!("Failed to parse introspect_token response: {e}"))
287        })
288    }
289
290    /// Query the admin audit log (admin only).
291    ///
292    /// Calls `GET /auth/audit` with optional query parameters.
293    /// Returns entries newest-first, bounded by `query.limit` (server default 200).
294    pub async fn list_audit_log(&self, query: AuditQuery) -> Result<Vec<AuditEntry>> {
295        // Build query-string from non-None fields (values are ASCII identifiers
296        // or RFC-3339 timestamps — no special encoding needed beyond standard).
297        let mut parts: Vec<String> = Vec::new();
298        if let Some(actor) = &query.actor {
299            parts.push(format!("actor={actor}"));
300        }
301        if let Some(action) = &query.action {
302            parts.push(format!("action={action}"));
303        }
304        if let Some(since) = &query.since {
305            parts.push(format!("since={since}"));
306        }
307        if let Some(until) = &query.until {
308            parts.push(format!("until={until}"));
309        }
310        if let Some(limit) = query.limit {
311            parts.push(format!("limit={limit}"));
312        }
313        let path = if parts.is_empty() {
314            "/auth/audit".to_string()
315        } else {
316            format!("/auth/audit?{}", parts.join("&"))
317        };
318        let response = self.make_request("GET", &path, None).await?;
319        let val: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
320            VectorizerError::server(format!("Failed to parse list_audit_log response: {e}"))
321        })?;
322        let arr = val
323            .get("entries")
324            .and_then(|e| e.as_array())
325            .cloned()
326            .unwrap_or_default();
327        arr.into_iter()
328            .map(|v| {
329                serde_json::from_value(v).map_err(|e| {
330                    VectorizerError::server(format!("Failed to parse audit entry: {e}"))
331                })
332            })
333            .collect()
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    #![allow(clippy::unwrap_used)]
340
341    use serde_json::json;
342
343    use crate::models::{
344        ApiKey, CreateApiKeyRequest, CreateUserRequest, JwtToken, PasswordPolicyReport, User,
345    };
346
347    #[test]
348    fn user_deserializes() {
349        let raw = json!({
350            "user_id": "u-1",
351            "username": "alice",
352            "roles": ["Admin", "User"]
353        });
354        let u: User = serde_json::from_value(raw).unwrap();
355        assert_eq!(u.username, "alice");
356        assert_eq!(u.roles.len(), 2);
357    }
358
359    #[test]
360    fn user_round_trip() {
361        let u = User {
362            user_id: "u-42".into(),
363            username: "bob".into(),
364            roles: vec!["User".into()],
365        };
366        let serialized = serde_json::to_value(&u).unwrap();
367        let parsed: User = serde_json::from_value(serialized).unwrap();
368        assert_eq!(parsed.user_id, "u-42");
369    }
370
371    #[test]
372    fn jwt_token_deserializes() {
373        let raw = json!({
374            "access_token": "eyJ...",
375            "token_type": "Bearer",
376            "expires_in": 3600
377        });
378        let t: JwtToken = serde_json::from_value(raw).unwrap();
379        assert_eq!(t.token_type, "Bearer");
380        assert_eq!(t.expires_in, 3600);
381    }
382
383    #[test]
384    fn password_policy_report_valid() {
385        let raw = json!({
386            "valid": true,
387            "errors": [],
388            "strength": 80,
389            "strength_label": "Strong"
390        });
391        let r: PasswordPolicyReport = serde_json::from_value(raw).unwrap();
392        assert!(r.valid);
393        assert_eq!(r.strength, 80);
394        assert_eq!(r.strength_label, "Strong");
395    }
396
397    #[test]
398    fn password_policy_report_invalid() {
399        let raw = json!({
400            "valid": false,
401            "errors": ["too short", "needs uppercase"],
402            "strength": 10,
403            "strength_label": "Very Weak"
404        });
405        let r: PasswordPolicyReport = serde_json::from_value(raw).unwrap();
406        assert!(!r.valid);
407        assert_eq!(r.errors.len(), 2);
408    }
409
410    #[test]
411    fn create_api_key_request_serializes() {
412        let req = CreateApiKeyRequest {
413            name: "ci-bot".into(),
414            permissions: vec!["Read".into(), "Write".into()],
415            expires_in: Some(86400),
416        };
417        let v = serde_json::to_value(&req).unwrap();
418        assert_eq!(v["name"], "ci-bot");
419        assert_eq!(v["expires_in"], 86400);
420    }
421
422    #[test]
423    fn api_key_deserializes_creation_response() {
424        let raw = json!({
425            "id": "key-1",
426            "name": "ci-bot",
427            "permissions": ["Read"],
428            "api_key": "sk-abc123",
429            "created_at": 1714608000u64,
430            "active": true,
431            "warning": "Store this key securely"
432        });
433        let k: ApiKey = serde_json::from_value(raw).unwrap();
434        assert_eq!(k.id, "key-1");
435        assert_eq!(k.api_key.as_deref(), Some("sk-abc123"));
436        assert!(k.active);
437    }
438
439    #[test]
440    fn api_key_deserializes_list_response() {
441        // api_key is omitted in list responses
442        let raw = json!({
443            "id": "key-2",
444            "name": "deploy",
445            "permissions": ["Write"],
446            "created_at": 1714608000u64,
447            "active": false
448        });
449        let k: ApiKey = serde_json::from_value(raw).unwrap();
450        assert!(k.api_key.is_none());
451        assert!(!k.active);
452    }
453
454    #[test]
455    fn create_user_request_serializes() {
456        let req = CreateUserRequest {
457            username: "charlie".into(),
458            password: "P@ssw0rd!".into(),
459            roles: vec!["User".into()],
460        };
461        let v = serde_json::to_value(&req).unwrap();
462        assert_eq!(v["username"], "charlie");
463    }
464
465    // ── phase15 auth admin ────────────────────────────────────────────────────
466
467    use crate::models::{
468        AuditEntry, AuditQuery, CreateScopedApiKeyRequest, RotatedKey, TokenIntrospection,
469        TokenScope,
470    };
471
472    #[test]
473    fn rotated_key_deserializes() {
474        let raw = json!({
475            "old_key_id": "key-old",
476            "new_key_id": "key-new",
477            "new_token": "sk-new-token",
478            "grace_until": 1714694400u64
479        });
480        let r: RotatedKey = serde_json::from_value(raw).unwrap();
481        assert_eq!(r.old_key_id, "key-old");
482        assert_eq!(r.new_key_id, "key-new");
483        assert_eq!(r.grace_until, 1714694400);
484    }
485
486    #[test]
487    fn create_scoped_api_key_request_serializes() {
488        let req = CreateScopedApiKeyRequest {
489            name: "scoped-key".into(),
490            permissions: vec!["Read".into()],
491            expires_in: Some(3600),
492            scopes: vec![TokenScope {
493                collection: "my-col".into(),
494                permissions: vec!["read".into(), "write".into()],
495            }],
496        };
497        let v = serde_json::to_value(&req).unwrap();
498        assert_eq!(v["name"], "scoped-key");
499        assert_eq!(v["scopes"][0]["collection"], "my-col");
500        assert_eq!(v["scopes"][0]["permissions"][1], "write");
501    }
502
503    #[test]
504    fn token_introspection_active_deserializes() {
505        let raw = json!({
506            "active": true,
507            "sub": "user-1",
508            "exp": 1714694400u64,
509            "username": "alice"
510        });
511        let t: TokenIntrospection = serde_json::from_value(raw).unwrap();
512        assert!(t.active);
513        assert_eq!(t.sub.as_deref(), Some("user-1"));
514        assert_eq!(t.username.as_deref(), Some("alice"));
515        assert!(t.scope.is_none());
516    }
517
518    #[test]
519    fn token_introspection_inactive_deserializes() {
520        let raw = json!({ "active": false });
521        let t: TokenIntrospection = serde_json::from_value(raw).unwrap();
522        assert!(!t.active);
523        assert!(t.sub.is_none());
524        assert!(t.exp.is_none());
525    }
526
527    #[test]
528    fn audit_entry_deserializes() {
529        let raw = json!({
530            "actor": "admin",
531            "action": "rotate_api_key",
532            "target": "key-1",
533            "at": "2026-05-02T12:00:00Z",
534            "correlation_id": "corr-abc"
535        });
536        let e: AuditEntry = serde_json::from_value(raw).unwrap();
537        assert_eq!(e.actor, "admin");
538        assert_eq!(e.action, "rotate_api_key");
539        assert_eq!(e.correlation_id.as_deref(), Some("corr-abc"));
540    }
541
542    #[test]
543    fn audit_entry_without_correlation_id_deserializes() {
544        let raw = json!({
545            "actor": "admin",
546            "action": "create_api_key",
547            "target": "key-2",
548            "at": "2026-05-02T13:00:00Z"
549        });
550        let e: AuditEntry = serde_json::from_value(raw).unwrap();
551        assert!(e.correlation_id.is_none());
552    }
553
554    #[test]
555    fn audit_query_serializes_with_defaults() {
556        let q = AuditQuery::default();
557        let v = serde_json::to_value(&q).unwrap();
558        // All fields should be absent (skip_serializing_if = None).
559        assert_eq!(v, json!({}));
560    }
561
562    #[test]
563    fn audit_query_serializes_partial() {
564        let q = AuditQuery {
565            actor: Some("admin".into()),
566            limit: Some(50),
567            ..Default::default()
568        };
569        let v = serde_json::to_value(&q).unwrap();
570        assert_eq!(v["actor"], "admin");
571        assert_eq!(v["limit"], 50);
572        assert!(v.get("action").is_none());
573    }
574}