use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use serde::Deserialize;
use vta_sdk::protocols::acl_management::{
create::{CreateAclResponseBody, CreateAclResultBody},
get::GetAclResultBody,
list::ListAclResultBody,
};
use crate::acl::{ApproveScope, ContextDirection, Role};
use crate::auth::{AdminAuth, AuthClaims, ManageAuth};
use crate::error::AppError;
use crate::operations;
use crate::server::AppState;
use crate::trust_tasks::{AclChangeRoleOp, AclGrantOp, AclRevokeOp, AclSwapKeyOp, RequireStepUp};
#[derive(Debug, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ListAclQuery {
pub context: Option<String>,
pub direction: Option<String>,
}
#[utoipa::path(
get, path = "/acl", tag = "acl",
security(("bearer_jwt" = [])),
params(ListAclQuery),
responses(
(status = 200, description = "ACL entries", body = ListAclResultBody),
(status = 400, description = "Unparseable `direction`, or a direction without a context"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller cannot manage ACL entries"),
),
)]
pub async fn list_acl(
auth: ManageAuth,
State(state): State<AppState>,
Query(query): Query<ListAclQuery>,
) -> Result<Json<ListAclResultBody>, AppError> {
let direction = parse_direction(query.direction.as_deref())?;
let result = operations::acl::list_entries(
&state.acl_ks,
&auth.0,
query.context.as_deref(),
direction,
"rest",
)
.await?;
Ok(Json(result))
}
fn parse_direction(raw: Option<&str>) -> Result<ContextDirection, AppError> {
match raw {
None => Ok(ContextDirection::default()),
Some(s) => s.parse().map_err(AppError::Validation),
}
}
pub type CreateAclRequest = vta_sdk::protocols::acl_management::create::CreateAclBody;
#[utoipa::path(
post, path = "/acl", tag = "acl",
security(("bearer_jwt" = [])),
request_body = CreateAclRequest,
responses(
(status = 201, description = "ACL entry created", body = CreateAclResponseBody),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller cannot manage ACL entries"),
),
)]
pub async fn create_acl(
auth: ManageAuth,
_step_up: RequireStepUp<AclGrantOp>,
State(state): State<AppState>,
Json(req): Json<CreateAclRequest>,
) -> Result<(StatusCode, Json<CreateAclResponseBody>), AppError> {
let result = operations::acl::grant_from_entry(
&state.acl_ks,
&state.audit_ks,
&state.contexts_ks,
&auth.0,
req.entry,
"rest",
)
.await?;
Ok((StatusCode::CREATED, Json(result)))
}
#[utoipa::path(
get, path = "/acl/{did}", tag = "acl",
security(("bearer_jwt" = [])),
params(("did" = String, Path, description = "Subject DID")),
responses(
(status = 200, description = "ACL entry", body = GetAclResultBody),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller cannot manage ACL entries"),
(status = 404, description = "ACL entry not found"),
),
)]
pub async fn get_acl(
auth: ManageAuth,
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<GetAclResultBody>, AppError> {
let result = operations::acl::show_by_subject(&state.acl_ks, &auth.0, &did, "rest").await?;
Ok(Json(result))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateAclRequest {
pub role: Option<Role>,
pub label: Option<String>,
pub allowed_contexts: Option<Vec<String>>,
#[serde(default)]
pub step_up_approver: Option<String>,
#[serde(default)]
pub step_up_require: Option<String>,
#[serde(default)]
pub approve_scope: Option<ApproveScope>,
#[serde(rename = "allowedKeys", default, deserialize_with = "double_option")]
pub allowed_keys: Option<Option<Vec<String>>>,
}
fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(de).map(Some)
}
#[utoipa::path(
patch, path = "/acl/{did}", tag = "acl",
security(("bearer_jwt" = [])),
params(("did" = String, Path, description = "Subject DID")),
request_body = UpdateAclRequest,
responses(
(status = 200, description = "ACL entry updated", body = CreateAclResponseBody),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin"),
(status = 404, description = "ACL entry not found"),
),
)]
pub async fn update_acl(
auth: AdminAuth,
_step_up: RequireStepUp<AclChangeRoleOp>,
State(state): State<AppState>,
Path(did): Path<String>,
Json(req): Json<UpdateAclRequest>,
) -> Result<Json<CreateAclResponseBody>, AppError> {
if let Some(role) = &req.role {
return Err(AppError::Validation(format!(
"role changes are not part of `acl/update`; they need the compare-and-swap that \
`acl/change-role` carries. Run: pnm acl change-role --did {did} --from \
<current-role> --to {role}"
)));
}
let result = operations::acl::update_from_params(
&state.acl_ks,
&state.audit_ks,
&state.contexts_ks,
&auth.0,
&did,
operations::acl::UpdateAclParams {
role: None,
label: req.label,
allowed_contexts: req.allowed_contexts,
step_up_approver: req.step_up_approver,
step_up_require: req.step_up_require,
approve_scope: req.approve_scope,
expires_at: None,
reason: None,
allowed_keys: req
.allowed_keys
.map(|r| r.map(|keys| keys.into_iter().collect())),
},
"rest",
)
.await?;
Ok(Json(result))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ChangeRoleRequest {
pub from_role: String,
pub to_role: String,
#[serde(default)]
pub reason: Option<String>,
}
#[utoipa::path(
post, path = "/acl/{did}/change-role", tag = "acl",
security(("bearer_jwt" = [])),
params(("did" = String, Path, description = "Subject DID")),
request_body = ChangeRoleRequest,
responses(
(status = 200, description = "Role changed", body = CreateAclResponseBody),
(status = 400, description = "Role not recognized"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller may not confer the target role"),
(status = 404, description = "ACL entry not found"),
(status = 409, description = "Stored role does not match fromRole"),
),
)]
pub async fn change_role(
auth: AdminAuth,
_step_up: RequireStepUp<AclChangeRoleOp>,
State(state): State<AppState>,
Path(did): Path<String>,
Json(req): Json<ChangeRoleRequest>,
) -> Result<Json<CreateAclResponseBody>, AppError> {
let stored = operations::acl::change_role(
&state.acl_ks,
&state.audit_ks,
&auth.0,
&did,
&req.from_role,
&req.to_role,
req.reason.as_deref(),
"rest",
)
.await?;
Ok(Json(CreateAclResponseBody {
entry: vta_sdk::protocols::acl_management::entry::AclEntry::from_result(&stored),
}))
}
#[utoipa::path(
delete, path = "/acl/{did}", tag = "acl",
security(("bearer_jwt" = [])),
params(("did" = String, Path, description = "Subject DID")),
responses(
(status = 204, description = "ACL entry removed"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller cannot manage ACL entries"),
(status = 404, description = "ACL entry not found"),
),
)]
pub async fn delete_acl(
auth: ManageAuth,
_step_up: RequireStepUp<AclRevokeOp>,
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<StatusCode, AppError> {
operations::acl::delete_acl(&state.acl_ks, &state.audit_ks, &auth.0, &did, "rest").await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[derive(utoipa::ToSchema)]
pub enum SwapAclRequest {
Canonical {
#[serde(alias = "current_subject")]
current_subject: String,
#[serde(alias = "new_subject")]
new_subject: String,
#[serde(alias = "link_proof")]
link_proof: String,
#[serde(default)]
#[allow(dead_code)]
reason: Option<String>,
},
Legacy {
presentation: String,
},
}
#[utoipa::path(
post, path = "/acl/swap", tag = "acl",
security(("bearer_jwt" = [])),
request_body = SwapAclRequest,
responses(
(status = 200, description = "ACL entry swapped onto the new DID", body = CreateAclResultBody),
(status = 401, description = "Missing or invalid bearer token"),
),
)]
pub async fn swap_acl(
auth: AuthClaims,
_step_up: RequireStepUp<AclSwapKeyOp>,
State(state): State<AppState>,
Json(req): Json<SwapAclRequest>,
) -> Result<Json<CreateAclResultBody>, AppError> {
let (presentation, claimed_new_subject) = match req {
SwapAclRequest::Canonical {
current_subject,
new_subject,
link_proof,
reason: _,
} => {
if current_subject != auth.did {
return Err(AppError::Validation(format!(
"acl/swap-key: currentSubject {} does not equal authenticated caller {}",
current_subject, auth.did
)));
}
(link_proof, Some(new_subject))
}
SwapAclRequest::Legacy { presentation } => (presentation, None),
};
let did_resolver = state
.did_resolver
.as_ref()
.ok_or_else(|| AppError::Internal("DID resolver not available".into()))?;
let vta_did = {
let config = state.config.read().await;
config
.vta_did
.clone()
.ok_or_else(|| AppError::Internal("VTA DID not configured".into()))?
};
let result = operations::acl::swap_acl(
&state.acl_ks,
&state.audit_ks,
&auth,
&presentation,
did_resolver,
&vta_did,
"rest",
)
.await?;
if let Some(claimed) = claimed_new_subject
&& claimed != result.did
{
return Err(AppError::Validation(format!(
"acl/swap-key: newSubject {} does not match verified VP holder {}",
claimed, result.did
)));
}
Ok(Json(result))
}