- Add TOTP enrollment/confirmation/removal to all clients - Add password change and admin set-password endpoints - Add account listing, status update, and tag management - Add audit log listing with filter support - Add policy rule CRUD operations - Expand test coverage for all new endpoints across clients - Fix .gitignore to exclude built binaries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
843 lines
27 KiB
Rust
843 lines
27 KiB
Rust
//! # 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<dyn std::error::Error>> {
|
|
//! 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<tokio::sync::RwLock<...>>` 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<String>,
|
|
#[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<String>,
|
|
/// Algorithm — always `"EdDSA"`. Validate this before trusting the key.
|
|
pub alg: Option<String>,
|
|
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<String>,
|
|
pub target_id: Option<String>,
|
|
pub details: Option<String>,
|
|
}
|
|
|
|
/// Paginated response from `GET /v1/audit`.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct AuditPage {
|
|
pub events: Vec<AuditEvent>,
|
|
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<u32>,
|
|
pub offset: Option<u32>,
|
|
pub event_type: Option<String>,
|
|
pub actor_id: Option<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
pub expires_at: Option<String>,
|
|
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<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub account_types: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub subject_uuid: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub actions: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub resource_type: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub owner_matches_subject: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub service_names: Option<Vec<String>>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub required_tags: Option<Vec<String>>,
|
|
}
|
|
|
|
/// 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<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub not_before: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expires_at: Option<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub priority: Option<i64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub enabled: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub rule: Option<RuleBody>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub not_before: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub expires_at: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub clear_not_before: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub clear_expires_at: Option<bool>,
|
|
}
|
|
|
|
// ---- 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<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TagsResponse {
|
|
tags: Vec<String>,
|
|
}
|
|
|
|
#[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<Vec<u8>>,
|
|
|
|
/// Optional pre-existing bearer token.
|
|
pub token: Option<String>,
|
|
}
|
|
|
|
// ---- 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<RwLock<...>>` so clones share the token.
|
|
/// Security: the token is never logged or included in error messages.
|
|
token: Arc<RwLock<Option<String>>>,
|
|
}
|
|
|
|
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<Self, MciasError> {
|
|
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<String> {
|
|
self.token.read().await.clone()
|
|
}
|
|
|
|
/// Replace the stored bearer token.
|
|
pub async fn set_token(&self, tok: Option<String>) {
|
|
*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<TokenClaims, MciasError> {
|
|
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<PublicKey, MciasError> {
|
|
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<Account, MciasError> {
|
|
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<Vec<Account>, MciasError> {
|
|
self.get("/v1/accounts").await
|
|
}
|
|
|
|
/// Get a single account by UUID.
|
|
pub async fn get_account(&self, id: &str) -> Result<Account, MciasError> {
|
|
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<Vec<String>, 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<PgCreds, MciasError> {
|
|
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.
|
|
pub async fn enroll_totp(&self) -> Result<(String, String), MciasError> {
|
|
let resp: TotpEnrollResponse =
|
|
self.post("/v1/auth/totp/enroll", &serde_json::json!({})).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<Vec<String>, 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<Vec<String>, 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<AuditPage, MciasError> {
|
|
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<Vec<PolicyRule>, MciasError> {
|
|
self.get("/v1/policy/rules").await
|
|
}
|
|
|
|
/// Create a new policy rule.
|
|
pub async fn create_policy_rule(
|
|
&self,
|
|
req: CreatePolicyRuleRequest,
|
|
) -> Result<PolicyRule, MciasError> {
|
|
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<PolicyRule, MciasError> {
|
|
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<PolicyRule, MciasError> {
|
|
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<header::HeaderValue> {
|
|
let guard = self.token.read().await;
|
|
guard.as_deref().and_then(|tok| {
|
|
header::HeaderValue::from_str(&format!("Bearer {tok}")).ok()
|
|
})
|
|
}
|
|
|
|
async fn get<T: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<T, 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.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<T: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
path: &str,
|
|
params: &[(&str, String)],
|
|
) -> Result<T, MciasError> {
|
|
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<B: Serialize, T: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
path: &str,
|
|
body: &B,
|
|
) -> Result<T, 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.decode(resp).await
|
|
}
|
|
|
|
async fn post_expect_status<B: Serialize, T: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
path: &str,
|
|
body: &B,
|
|
expected: StatusCode,
|
|
) -> Result<T, 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?;
|
|
if resp.status() == expected {
|
|
return resp
|
|
.json::<T>()
|
|
.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<B: Serialize>(&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<B: Serialize, T: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
path: &str,
|
|
body: &B,
|
|
) -> Result<T, 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.decode(resp).await
|
|
}
|
|
|
|
async fn patch_no_content<B: Serialize>(&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<B: Serialize + ?Sized>(&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<B: Serialize, T: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
path: &str,
|
|
body: &B,
|
|
) -> Result<T, 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.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<B: Serialize>(&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<T: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
resp: reqwest::Response,
|
|
) -> Result<T, MciasError> {
|
|
if resp.status().is_success() {
|
|
return resp
|
|
.json::<T>()
|
|
.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::<ErrorResponse>()
|
|
.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,
|
|
},
|
|
}
|
|
}
|
|
}
|