//! # mcias-client //! //! Async Rust client for the MCIAS (Metacircular Identity and Access System) //! REST API. //! //! ## Usage //! //! ```rust,no_run //! use mcias_client::{Client, ClientOptions}; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! let client = Client::new("https://auth.example.com", ClientOptions::default())?; //! //! let (token, expires_at) = client.login("alice", "s3cret", None).await?; //! println!("Logged in, token expires at {expires_at}"); //! //! client.logout().await?; //! Ok(()) //! } //! ``` //! //! ## Thread Safety //! //! [`Client`] is `Clone + Send + Sync`. The internally stored bearer token is //! protected by an `Arc>` so concurrent async tasks //! may share a single client safely. use std::sync::Arc; use reqwest::{header, Certificate, StatusCode}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; // ---- Error types ---- /// All errors returned by the MCIAS client. #[derive(Debug, thiserror::Error)] pub enum MciasError { /// HTTP 401 — authentication required or credentials invalid. #[error("authentication error: {0}")] Auth(String), /// HTTP 403 — caller lacks required role. #[error("permission denied: {0}")] Forbidden(String), /// HTTP 404 — requested resource does not exist. #[error("not found: {0}")] NotFound(String), /// HTTP 400 — the request payload was invalid. #[error("invalid input: {0}")] InvalidInput(String), /// HTTP 409 — resource conflict (e.g. duplicate username). #[error("conflict: {0}")] Conflict(String), /// HTTP 5xx — the server returned an internal error. #[error("server error ({status}): {message}")] Server { status: u16, message: String }, /// Transport-level error (DNS failure, connection refused, timeout, etc.). #[error("transport error: {0}")] Transport(#[from] reqwest::Error), /// Response body could not be decoded. #[error("decode error: {0}")] Decode(String), } // ---- Public data types ---- /// Account information returned by the server. #[derive(Debug, Clone, Deserialize)] pub struct Account { pub id: String, pub username: String, pub account_type: String, pub status: String, pub created_at: String, pub updated_at: String, pub totp_enabled: bool, } /// Result of a token validation request. #[derive(Debug, Clone, Deserialize)] pub struct TokenClaims { pub valid: bool, #[serde(default)] pub sub: String, #[serde(default)] pub roles: Vec, #[serde(default)] pub expires_at: String, } /// The server's Ed25519 public key in JWK format. #[derive(Debug, Clone, Deserialize)] pub struct PublicKey { pub kty: String, pub crv: String, /// Key use — always `"sig"` for the MCIAS signing key. #[serde(rename = "use")] pub key_use: Option, /// Algorithm — always `"EdDSA"`. Validate this before trusting the key. pub alg: Option, pub x: String, } /// Postgres credentials returned by the server. #[derive(Debug, Clone, Deserialize)] pub struct PgCreds { pub host: String, pub port: u16, pub database: String, pub username: String, pub password: String, } /// Audit log entry returned by `GET /v1/audit`. #[derive(Debug, Clone, Deserialize)] pub struct AuditEvent { pub id: i64, pub event_type: String, pub event_time: String, pub ip_address: String, pub actor_id: Option, pub target_id: Option, pub details: Option, } /// Paginated response from `GET /v1/audit`. #[derive(Debug, Clone, Deserialize)] pub struct AuditPage { pub events: Vec, pub total: i64, pub limit: i64, pub offset: i64, } /// Query parameters for `GET /v1/audit`. #[derive(Debug, Clone, Default)] pub struct AuditQuery { pub limit: Option, pub offset: Option, pub event_type: Option, pub actor_id: Option, } /// A single operator-defined policy rule. #[derive(Debug, Clone, Deserialize)] pub struct PolicyRule { pub id: i64, pub priority: i64, pub description: String, pub rule: RuleBody, pub enabled: bool, pub not_before: Option, pub expires_at: Option, pub created_at: String, pub updated_at: String, } /// The match conditions and effect of a policy rule. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuleBody { pub effect: String, #[serde(skip_serializing_if = "Option::is_none")] pub roles: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub account_types: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub subject_uuid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub actions: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub resource_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub owner_matches_subject: Option, #[serde(skip_serializing_if = "Option::is_none")] pub service_names: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub required_tags: Option>, } /// Request body for `POST /v1/policy/rules`. #[derive(Debug, Clone, Serialize)] pub struct CreatePolicyRuleRequest { pub description: String, pub rule: RuleBody, #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, #[serde(skip_serializing_if = "Option::is_none")] pub not_before: Option, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option, } /// Request body for `PATCH /v1/policy/rules/{id}`. #[derive(Debug, Clone, Serialize, Default)] pub struct UpdatePolicyRuleRequest { #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, #[serde(skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rule: Option, #[serde(skip_serializing_if = "Option::is_none")] pub not_before: Option, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub clear_not_before: Option, #[serde(skip_serializing_if = "Option::is_none")] pub clear_expires_at: Option, } // ---- Internal request/response types ---- #[derive(Serialize)] struct LoginRequest<'a> { username: &'a str, password: &'a str, #[serde(skip_serializing_if = "Option::is_none")] totp_code: Option<&'a str>, } #[derive(Deserialize)] struct TokenResponse { token: String, expires_at: String, } #[derive(Deserialize)] struct ErrorResponse { #[serde(default)] error: String, } #[derive(Deserialize)] struct RolesResponse { roles: Vec, } #[derive(Deserialize)] struct TagsResponse { tags: Vec, } #[derive(Deserialize)] struct TotpEnrollResponse { secret: String, otpauth_uri: String, } // ---- Client options ---- /// Configuration options for the MCIAS client. #[derive(Debug, Default, Clone)] pub struct ClientOptions { /// Optional PEM-encoded CA certificate for TLS verification. /// Use when connecting to a server with a self-signed or private-CA cert. pub ca_cert_pem: Option>, /// Optional pre-existing bearer token. pub token: Option, } // ---- Client ---- /// Async MCIAS REST API client. /// /// `Client` is cheaply cloneable — the internal HTTP client and token storage /// are reference-counted. All clones share the same token. #[derive(Clone)] pub struct Client { base_url: String, http: reqwest::Client, /// Bearer token storage. `Arc>` so clones share the token. /// Security: the token is never logged or included in error messages. token: Arc>>, } impl Client { /// Create a new client. /// /// `base_url` must be an HTTPS URL (e.g. `"https://auth.example.com"`). /// TLS 1.2+ is enforced by the underlying `reqwest` / `rustls` stack. pub fn new(base_url: &str, opts: ClientOptions) -> Result { let mut builder = reqwest::ClientBuilder::new() // Security: enforce TLS 1.2+ minimum. .min_tls_version(reqwest::tls::Version::TLS_1_2) .use_rustls_tls(); if let Some(pem) = opts.ca_cert_pem { let cert = Certificate::from_pem(&pem) .map_err(|e| MciasError::Decode(format!("parse CA cert: {e}")))?; builder = builder.add_root_certificate(cert); } let http = builder.build()?; Ok(Self { base_url: base_url.trim_end_matches('/').to_owned(), http, token: Arc::new(RwLock::new(opts.token)), }) } /// Return the currently stored bearer token, if any. pub async fn token(&self) -> Option { self.token.read().await.clone() } /// Replace the stored bearer token. pub async fn set_token(&self, tok: Option) { *self.token.write().await = tok; } // ---- Authentication ---- /// Login with username and password. On success stores the returned token /// and returns `(token, expires_at)`. /// /// `totp_code` may be `None` when TOTP is not enrolled. pub async fn login( &self, username: &str, password: &str, totp_code: Option<&str>, ) -> Result<(String, String), MciasError> { let body = LoginRequest { username, password, totp_code, }; let resp: TokenResponse = self.post("/v1/auth/login", &body).await?; *self.token.write().await = Some(resp.token.clone()); Ok((resp.token, resp.expires_at)) } /// Logout — revoke the current token on the server. Clears the stored token. pub async fn logout(&self) -> Result<(), MciasError> { self.post_empty("/v1/auth/logout").await?; *self.token.write().await = None; Ok(()) } /// Renew the current token. The old token is revoked server-side; the new /// token is stored and returned as `(token, expires_at)`. pub async fn renew_token(&self) -> Result<(String, String), MciasError> { let resp: TokenResponse = self.post("/v1/auth/renew", &serde_json::json!({})).await?; *self.token.write().await = Some(resp.token.clone()); Ok((resp.token, resp.expires_at)) } /// Validate a token. Returns [`TokenClaims`] with `valid: false` (no error) /// if the token is invalid or revoked. pub async fn validate_token(&self, token: &str) -> Result { let body = serde_json::json!({ "token": token }); self.post("/v1/token/validate", &body).await } // ---- Server information ---- /// Call the health endpoint. Returns `Ok(())` on HTTP 200. pub async fn health(&self) -> Result<(), MciasError> { self.get_empty("/v1/health").await } /// Return the server's Ed25519 public key in JWK format. pub async fn get_public_key(&self) -> Result { self.get("/v1/keys/public").await } // ---- Account management (admin only) ---- /// Create a new account. `account_type` must be `"human"` or `"system"`. pub async fn create_account( &self, username: &str, password: Option<&str>, account_type: &str, ) -> Result { let mut body = serde_json::json!({ "username": username, "account_type": account_type, }); if let Some(pw) = password { body["password"] = serde_json::Value::String(pw.to_owned()); } self.post_expect_status("/v1/accounts", &body, StatusCode::CREATED) .await } /// List all accounts. pub async fn list_accounts(&self) -> Result, MciasError> { self.get("/v1/accounts").await } /// Get a single account by UUID. pub async fn get_account(&self, id: &str) -> Result { self.get(&format!("/v1/accounts/{id}")).await } /// Update an account's status. Allowed values: `"active"`, `"inactive"`. pub async fn update_account(&self, id: &str, status: &str) -> Result<(), MciasError> { let body = serde_json::json!({ "status": status }); self.patch_no_content(&format!("/v1/accounts/{id}"), &body).await } /// Soft-delete an account and revoke all its tokens. pub async fn delete_account(&self, id: &str) -> Result<(), MciasError> { self.delete(&format!("/v1/accounts/{id}")).await } // ---- Role management (admin only) ---- /// Get all roles assigned to an account. pub async fn get_roles(&self, account_id: &str) -> Result, MciasError> { // Security: spec wraps roles in {"roles": [...]}, unwrap before returning. let resp: RolesResponse = self.get(&format!("/v1/accounts/{account_id}/roles")).await?; Ok(resp.roles) } /// Replace the complete role set for an account. pub async fn set_roles(&self, account_id: &str, roles: &[&str]) -> Result<(), MciasError> { let url = format!("/v1/accounts/{account_id}/roles"); // Spec requires {"roles": [...]} wrapper. let body = serde_json::json!({ "roles": roles }); self.put_no_content(&url, &body).await } // ---- Token management (admin only) ---- /// Issue a long-lived token for a system account. pub async fn issue_service_token( &self, account_id: &str, ) -> Result<(String, String), MciasError> { let body = serde_json::json!({ "account_id": account_id }); let resp: TokenResponse = self.post("/v1/token/issue", &body).await?; Ok((resp.token, resp.expires_at)) } /// Revoke a token by JTI. pub async fn revoke_token(&self, jti: &str) -> Result<(), MciasError> { self.delete(&format!("/v1/token/{jti}")).await } // ---- PG credentials (admin only) ---- /// Get decrypted Postgres credentials for an account. pub async fn get_pg_creds(&self, account_id: &str) -> Result { self.get(&format!("/v1/accounts/{account_id}/pgcreds")) .await } /// Store Postgres credentials for an account. pub async fn set_pg_creds( &self, account_id: &str, host: &str, port: u16, database: &str, username: &str, password: &str, ) -> Result<(), MciasError> { let body = serde_json::json!({ "host": host, "port": port, "database": database, "username": username, "password": password, }); self.put_no_content(&format!("/v1/accounts/{account_id}/pgcreds"), &body) .await } // ---- TOTP enrollment (authenticated) ---- /// Begin TOTP enrollment. Returns `(secret, otpauth_uri)`. /// The secret is shown once; store it in an authenticator app immediately. /// /// Security (SEC-01): current password is required to prevent session-theft /// escalation to persistent account takeover. pub async fn enroll_totp(&self, password: &str) -> Result<(String, String), MciasError> { let resp: TotpEnrollResponse = self.post("/v1/auth/totp/enroll", &serde_json::json!({"password": password})).await?; Ok((resp.secret, resp.otpauth_uri)) } /// Confirm TOTP enrollment with the current 6-digit code. /// On success, TOTP becomes required for all future logins. pub async fn confirm_totp(&self, code: &str) -> Result<(), MciasError> { let body = serde_json::json!({ "code": code }); self.post_empty_body("/v1/auth/totp/confirm", &body).await } // ---- Password management ---- /// Change the caller's own password (self-service). Requires the current /// password to guard against token-theft attacks. pub async fn change_password( &self, current_password: &str, new_password: &str, ) -> Result<(), MciasError> { let body = serde_json::json!({ "current_password": current_password, "new_password": new_password, }); self.put_no_content("/v1/auth/password", &body).await } // ---- Admin: TOTP removal ---- /// Remove TOTP enrollment from an account (admin). Use for recovery when /// a user loses their TOTP device. pub async fn remove_totp(&self, account_id: &str) -> Result<(), MciasError> { let body = serde_json::json!({ "account_id": account_id }); self.delete_with_body("/v1/auth/totp", &body).await } // ---- Admin: password reset ---- /// Reset an account's password without requiring the current password. pub async fn admin_set_password( &self, account_id: &str, new_password: &str, ) -> Result<(), MciasError> { let body = serde_json::json!({ "new_password": new_password }); self.put_no_content(&format!("/v1/accounts/{account_id}/password"), &body) .await } // ---- Account tags (admin) ---- /// Get all tags for an account. pub async fn get_tags(&self, account_id: &str) -> Result, MciasError> { let resp: TagsResponse = self.get(&format!("/v1/accounts/{account_id}/tags")).await?; Ok(resp.tags) } /// Replace the full tag set for an account atomically. Pass an empty slice /// to clear all tags. Returns the updated tag list. pub async fn set_tags( &self, account_id: &str, tags: &[&str], ) -> Result, MciasError> { let body = serde_json::json!({ "tags": tags }); let resp: TagsResponse = self.put_with_response(&format!("/v1/accounts/{account_id}/tags"), &body).await?; Ok(resp.tags) } // ---- Audit log (admin) ---- /// Query the audit log. Returns a paginated [`AuditPage`]. pub async fn list_audit(&self, query: AuditQuery) -> Result { let mut params: Vec<(&str, String)> = Vec::new(); if let Some(limit) = query.limit { params.push(("limit", limit.to_string())); } if let Some(offset) = query.offset { params.push(("offset", offset.to_string())); } if let Some(ref et) = query.event_type { params.push(("event_type", et.clone())); } if let Some(ref aid) = query.actor_id { params.push(("actor_id", aid.clone())); } self.get_with_query("/v1/audit", ¶ms).await } // ---- Policy rules (admin) ---- /// List all operator-defined policy rules ordered by priority. pub async fn list_policy_rules(&self) -> Result, MciasError> { self.get("/v1/policy/rules").await } /// Create a new policy rule. pub async fn create_policy_rule( &self, req: CreatePolicyRuleRequest, ) -> Result { self.post_expect_status("/v1/policy/rules", &req, StatusCode::CREATED) .await } /// Get a single policy rule by ID. pub async fn get_policy_rule(&self, id: i64) -> Result { self.get(&format!("/v1/policy/rules/{id}")).await } /// Update a policy rule. Omitted fields are left unchanged. pub async fn update_policy_rule( &self, id: i64, req: UpdatePolicyRuleRequest, ) -> Result { self.patch(&format!("/v1/policy/rules/{id}"), &req).await } /// Delete a policy rule permanently. pub async fn delete_policy_rule(&self, id: i64) -> Result<(), MciasError> { self.delete(&format!("/v1/policy/rules/{id}")).await } // ---- HTTP helpers ---- /// Build the Authorization header value from the stored token. /// Security: the token is read under a read-lock and is never logged. async fn auth_header(&self) -> Option { let guard = self.token.read().await; guard.as_deref().and_then(|tok| { header::HeaderValue::from_str(&format!("Bearer {tok}")).ok() }) } async fn get Deserialize<'de>>(&self, path: &str) -> Result { let mut req = self.http.get(format!("{}{path}", self.base_url)); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.decode(resp).await } async fn get_empty(&self, path: &str) -> Result<(), MciasError> { let mut req = self.http.get(format!("{}{path}", self.base_url)); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn get_with_query Deserialize<'de>>( &self, path: &str, params: &[(&str, String)], ) -> Result { let mut req = self .http .get(format!("{}{path}", self.base_url)) .query(params); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.decode(resp).await } async fn post Deserialize<'de>>( &self, path: &str, body: &B, ) -> Result { let mut req = self .http .post(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.decode(resp).await } async fn post_expect_status Deserialize<'de>>( &self, path: &str, body: &B, expected: StatusCode, ) -> Result { let mut req = self .http .post(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; if resp.status() == expected { return resp .json::() .await .map_err(|e| MciasError::Decode(e.to_string())); } Err(self.error_from_response(resp).await) } async fn post_empty(&self, path: &str) -> Result<(), MciasError> { let mut req = self .http .post(format!("{}{path}", self.base_url)) .header(header::CONTENT_LENGTH, "0"); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } /// POST with a JSON body that expects a 2xx (no body) response. async fn post_empty_body(&self, path: &str, body: &B) -> Result<(), MciasError> { let mut req = self .http .post(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn patch Deserialize<'de>>( &self, path: &str, body: &B, ) -> Result { let mut req = self .http .patch(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.decode(resp).await } async fn patch_no_content(&self, path: &str, body: &B) -> Result<(), MciasError> { let mut req = self .http .patch(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn put_no_content(&self, path: &str, body: &B) -> Result<(), MciasError> { let mut req = self .http .put(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn put_with_response Deserialize<'de>>( &self, path: &str, body: &B, ) -> Result { let mut req = self .http .put(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.decode(resp).await } async fn delete(&self, path: &str) -> Result<(), MciasError> { let mut req = self.http.delete(format!("{}{path}", self.base_url)); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } /// DELETE with a JSON request body (used by `DELETE /v1/auth/totp`). async fn delete_with_body(&self, path: &str, body: &B) -> Result<(), MciasError> { let mut req = self .http .delete(format!("{}{path}", self.base_url)) .json(body); if let Some(auth) = self.auth_header().await { req = req.header(header::AUTHORIZATION, auth); } let resp = req.send().await?; self.expect_success(resp).await } async fn decode Deserialize<'de>>( &self, resp: reqwest::Response, ) -> Result { if resp.status().is_success() { return resp .json::() .await .map_err(|e| MciasError::Decode(e.to_string())); } Err(self.error_from_response(resp).await) } async fn expect_success(&self, resp: reqwest::Response) -> Result<(), MciasError> { if resp.status().is_success() { return Ok(()); } Err(self.error_from_response(resp).await) } async fn error_from_response(&self, resp: reqwest::Response) -> MciasError { let status = resp.status(); let message = resp .json::() .await .map(|e| if e.error.is_empty() { status.to_string() } else { e.error }) .unwrap_or_else(|_| status.to_string()); match status { StatusCode::UNAUTHORIZED => MciasError::Auth(message), StatusCode::FORBIDDEN => MciasError::Forbidden(message), StatusCode::NOT_FOUND => MciasError::NotFound(message), StatusCode::BAD_REQUEST => MciasError::InvalidInput(message), StatusCode::CONFLICT => MciasError::Conflict(message), s => MciasError::Server { status: s.as_u16(), message, }, } } }