use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::{DateTime, Utc};
use fraiseql_storage::{
PolicyRuleSpec, PolicySource, StorageState, parse_policy, policy_source, policy_to_specs,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct BucketPolicyResponse {
pub bucket: String,
pub source: PolicySource,
pub access: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<Vec<PolicyRuleSpec>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PutBucketPolicyRequest {
pub rules: Vec<PolicyRuleSpec>,
}
#[derive(Debug)]
pub enum PolicyApiError {
UnknownBucket(String),
InvalidPolicy {
rule_index: Option<usize>,
message: String,
},
Store(String),
}
impl IntoResponse for PolicyApiError {
fn into_response(self) -> Response {
let (status, code, body) = match self {
Self::UnknownBucket(bucket) => (
StatusCode::NOT_FOUND,
"bucket_not_found",
serde_json::json!({
"message": format!("no bucket named {bucket:?} is configured"),
}),
),
Self::InvalidPolicy {
rule_index,
message,
} => (
StatusCode::BAD_REQUEST,
"invalid_policy",
serde_json::json!({
"message": message,
"rule_index": rule_index,
"policy_in_force": "unchanged",
}),
),
Self::Store(message) => (
StatusCode::INTERNAL_SERVER_ERROR,
"policy_store_unavailable",
serde_json::json!({
"message": message,
"policy_in_force": "unchanged",
}),
),
};
let mut payload = body;
if let Some(object) = payload.as_object_mut() {
object.insert("error".to_string(), serde_json::Value::from(code));
}
(status, Json(payload)).into_response()
}
}
pub async fn get_bucket_policy_handler(
State(state): State<StorageState>,
Path(bucket): Path<String>,
) -> Result<Json<BucketPolicyResponse>, PolicyApiError> {
let stored = state
.policy_store
.get(&bucket)
.await
.map_err(|e| PolicyApiError::Store(e.to_string()))?;
describe(&state, &bucket, stored.map(|row| row.updated_at))
.ok_or(PolicyApiError::UnknownBucket(bucket))
}
pub async fn put_bucket_policy_handler(
State(state): State<StorageState>,
Path(bucket): Path<String>,
Json(raw): Json<serde_json::Value>,
) -> Result<Json<BucketPolicyResponse>, PolicyApiError> {
if !state.buckets.load().contains_key(&bucket) {
return Err(PolicyApiError::UnknownBucket(bucket));
}
let body: PutBucketPolicyRequest =
serde_json::from_value(raw).map_err(|e| PolicyApiError::InvalidPolicy {
rule_index: None,
message: e.to_string(),
})?;
let policy = parse_policy(&body.rules).map_err(|e| PolicyApiError::InvalidPolicy {
rule_index: e.rule_index,
message: e.message,
})?;
let row = state
.policy_store
.put(&bucket, &body.rules)
.await
.map_err(|e| PolicyApiError::Store(e.to_string()))?;
if !state.set_bucket_policies(&bucket, &Some(policy)) {
return Err(PolicyApiError::Store(format!(
"bucket {bucket:?} disappeared while the policy was being applied; it is stored and \
will take effect on restart"
)));
}
tracing::info!(bucket = %bucket, rules = body.rules.len(), "storage bucket policy replaced");
describe(&state, &bucket, Some(row.updated_at)).ok_or(PolicyApiError::UnknownBucket(bucket))
}
pub async fn delete_bucket_policy_handler(
State(state): State<StorageState>,
Path(bucket): Path<String>,
) -> Result<Json<BucketPolicyResponse>, PolicyApiError> {
if !state.buckets.load().contains_key(&bucket) {
return Err(PolicyApiError::UnknownBucket(bucket));
}
let existed = state
.policy_store
.delete(&bucket)
.await
.map_err(|e| PolicyApiError::Store(e.to_string()))?;
let reverted = state.config_policy(&bucket).cloned();
let _applied = state.set_bucket_policies(&bucket, &reverted);
tracing::info!(
bucket = %bucket,
had_stored_policy = existed,
"storage bucket policy reverted to its configured source"
);
describe(&state, &bucket, None).ok_or(PolicyApiError::UnknownBucket(bucket))
}
fn describe(
state: &StorageState,
bucket: &str,
updated_at: Option<DateTime<Utc>>,
) -> Option<Json<BucketPolicyResponse>> {
let buckets = state.buckets.load();
let config = buckets.get(bucket)?;
let source = policy_source(updated_at.is_some(), state.config_policy(bucket).is_some());
Some(Json(BucketPolicyResponse {
bucket: bucket.to_string(),
source,
access: config.access.as_str(),
rules: config.policies.as_ref().map(policy_to_specs),
updated_at: match source {
PolicySource::Store => updated_at,
PolicySource::ConfigFile | PolicySource::AccessMode => None,
},
}))
}