use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::acl::{
ActScope, VtcAclEntry, VtcRole, as_vti_role, delete_acl_entry, get_acl_entry,
is_acl_entry_visible, list_acl_entries, store_acl_entry, validate_acl_modification,
validate_vtc_role_assignment,
};
use crate::auth::{AdminAuth, AuthClaims, ManageAuth, session::now_epoch};
use crate::error::AppError;
use crate::server::AppState;
use vti_common::audit::{AclChangeData, AclRevokedData, AuditEvent};
use vti_common::pagination::{Cursor, MAX_LIMIT};
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AclListResponse {
pub entries: Vec<AclEntryResponse>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct AclEntryResponse {
pub subject: String,
pub role: VtcRole,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub scopes: Vec<String>,
pub created_at: String,
pub created_by: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
fn epoch_to_rfc3339(secs: u64) -> String {
chrono::DateTime::from_timestamp(secs as i64, 0)
.unwrap_or_default()
.to_rfc3339()
}
impl From<VtcAclEntry> for AclEntryResponse {
fn from(e: VtcAclEntry) -> Self {
AclEntryResponse {
subject: e.did,
role: e.role,
label: e.label,
scopes: e.allowed_contexts,
created_at: epoch_to_rfc3339(e.created_at),
created_by: e.created_by,
updated_at: e.updated_at.map(epoch_to_rfc3339),
updated_by: e.updated_by,
expires_at: e.expires_at.map(epoch_to_rfc3339),
}
}
}
#[derive(Debug, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
#[into_params(parameter_in = Query)]
pub struct ListAclQuery {
pub role: Option<String>,
pub scope: Option<String>,
pub subject_prefix: Option<String>,
pub page_size: Option<usize>,
pub cursor: Option<String>,
}
impl ListAclQuery {
fn cursor_binding(&self) -> Vec<u8> {
let mut out = Vec::new();
let mut field = |v: Option<&str>| {
let b = v.unwrap_or("").as_bytes();
out.extend_from_slice(&(b.len() as u32).to_be_bytes());
out.extend_from_slice(b);
};
field(self.role.as_deref());
field(self.scope.as_deref());
field(self.subject_prefix.as_deref());
out
}
fn matches(&self, e: &VtcAclEntry) -> bool {
if let Some(role) = &self.role
&& e.role.to_string() != *role
{
return false;
}
if let Some(scope) = &self.scope
&& !e
.allowed_contexts
.iter()
.any(|allowed| vti_common::context_path::is_ancestor_or_self(allowed, scope))
{
return false;
}
if let Some(prefix) = &self.subject_prefix
&& !e.did.starts_with(prefix.as_str())
{
return false;
}
true
}
}
#[utoipa::path(
get, path = "/acl", tag = "acl",
security(("bearer_jwt" = [])),
params(ListAclQuery),
responses(
(status = 200, description = "Visible ACL entries", body = AclListResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller lacks manage authority"),
),
)]
pub async fn list_acl(
auth: ManageAuth,
State(state): State<AppState>,
Query(query): Query<ListAclQuery>,
) -> Result<Json<AclListResponse>, AppError> {
let acl = state.acl_ks.clone();
let limit = query.page_size.unwrap_or(50).clamp(1, MAX_LIMIT);
let mut matching: Vec<VtcAclEntry> = list_acl_entries(&acl)
.await?
.into_iter()
.filter(|e| is_acl_entry_visible(&auth.0, &as_vti_acl_entry(e)))
.filter(|e| query.matches(e))
.collect();
matching.sort_by(|a, b| a.did.cmp(&b.did));
let audit_key = match state.audit_writer.as_ref() {
Some(w) => Some(w.active_key().await?),
None => None,
};
let binding = query.cursor_binding();
let start = match (&query.cursor, &audit_key) {
(Some(wire), Some(key)) => {
let c = Cursor::decode_bound(wire, &key.key, &binding)?;
matching
.iter()
.position(|e| e.did.as_bytes() > c.last_key.as_slice())
.unwrap_or(matching.len())
}
(Some(_), None) => return Err(AppError::InvalidCursor),
(None, _) => 0,
};
let take = if audit_key.is_some() {
limit
} else {
matching.len()
};
let page: Vec<VtcAclEntry> = matching[start..].iter().take(take).cloned().collect();
let truncated = start + page.len() < matching.len();
let cursor = match (&audit_key, truncated) {
(Some(key), true) => page.last().map(|e| {
Cursor::new(e.did.as_bytes().to_vec(), matching.len() as u64)
.encode_bound(&key.key, &binding)
}),
_ => None,
};
let entries: Vec<AclEntryResponse> = page.into_iter().map(AclEntryResponse::from).collect();
info!(caller = %auth.0.did, count = entries.len(), truncated, "ACL listed");
Ok(Json(AclListResponse {
entries,
truncated,
cursor,
}))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateAclRequest {
pub entry: GrantEntry,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GrantEntry {
pub subject: String,
pub role: VtcRole,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
}
#[utoipa::path(
post, path = "/acl", tag = "acl",
security(("bearer_jwt" = [])),
request_body = CreateAclRequest,
responses(
(status = 201, description = "ACL entry created", body = AclEntryResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller lacks manage authority"),
),
)]
pub async fn create_acl(
auth: ManageAuth,
State(state): State<AppState>,
Json(req): Json<CreateAclRequest>,
) -> Result<(StatusCode, Json<AclEntryResponse>), AppError> {
let req_entry = req.entry;
validate_vtc_role_assignment(&auth.0, &req_entry.role)?;
validate_acl_modification(&auth.0, &as_vti_role(&req_entry.role), &req_entry.scopes)?;
let acl = state.acl_ks.clone();
let expires_at = req_entry.expires_at.map(|t| t.timestamp() as u64);
let existing = get_acl_entry(&acl, &req_entry.subject).await?;
let (created_at, created_by, status) = match existing {
Some(prev) => {
if !is_acl_entry_visible(&auth.0, &as_vti_acl_entry(&prev)) {
return Err(AppError::NotFound(format!(
"ACL entry not found for DID: {}",
req_entry.subject
)));
}
if prev.role != req_entry.role {
return Err(AppError::Conflict(format!(
"ACL entry for {} already holds role {}; use acl/change-role \
(PATCH /v1/acl/{}) to move it to {}",
req_entry.subject, prev.role, req_entry.subject, req_entry.role
)));
}
(prev.created_at, prev.created_by, StatusCode::OK)
}
None => (now_epoch(), auth.0.did.clone(), StatusCode::CREATED),
};
let entry = VtcAclEntry {
did: req_entry.subject,
role: req_entry.role,
label: req_entry.label,
allowed_contexts: req_entry.scopes,
created_at,
created_by,
updated_at: (status == StatusCode::OK).then(now_epoch),
updated_by: (status == StatusCode::OK).then(|| auth.0.did.clone()),
expires_at,
};
store_acl_entry(&acl, &entry).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&entry.created_by,
Some(&entry.did),
AuditEvent::AclGranted(AclChangeData {
did: entry.did.clone(),
role: entry.role.to_string(),
contexts: entry.allowed_contexts.clone(),
expires_at: entry.expires_at.map(|e| e.to_string()),
}),
)
.await?;
}
info!(
caller = %auth.0.did,
did = %entry.did,
role = %entry.role,
reason = req.reason.as_deref().unwrap_or(""),
created = status == StatusCode::CREATED,
"ACL entry granted",
);
Ok((status, Json(AclEntryResponse::from(entry))))
}
#[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 = AclEntryResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller lacks manage authority"),
(status = 404, description = "ACL entry not found"),
),
)]
pub async fn get_acl(
auth: ManageAuth,
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<AclEntryResponse>, AppError> {
let acl = state.acl_ks.clone();
let entry = get_acl_entry(&acl, &did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
if !is_acl_entry_visible(&auth.0, &as_vti_acl_entry(&entry)) {
return Err(AppError::NotFound(format!(
"ACL entry not found for DID: {did}"
)));
}
info!(did = %did, "ACL entry retrieved");
Ok(Json(AclEntryResponse::from(entry)))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateAclRequest {
pub from_role: VtcRole,
pub to_role: VtcRole,
#[serde(default)]
pub reason: Option<String>,
}
#[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 = "Updated ACL entry", body = AclEntryResponse),
(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,
State(state): State<AppState>,
Path(did): Path<String>,
Json(req): Json<UpdateAclRequest>,
) -> Result<Json<AclEntryResponse>, AppError> {
let acl = state.acl_ks.clone();
let mut entry = get_acl_entry(&acl, &did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
if !is_acl_entry_visible(&auth.0, &as_vti_acl_entry(&entry)) {
return Err(AppError::NotFound(format!(
"ACL entry not found for DID: {did}"
)));
}
if entry.role == VtcRole::Admin && !caller_covers_admin_target(&auth.0, &entry) {
return Err(AppError::Forbidden(
"cannot modify an admin entry scoped outside your contexts".into(),
));
}
let prev_role = entry.role.clone();
let prev_contexts = entry.allowed_contexts.clone();
if entry.role != req.from_role {
return Err(AppError::Conflict(format!(
"state mismatch: {did} currently holds role {}, not {}",
entry.role, req.from_role
)));
}
validate_vtc_role_assignment(&auth.0, &req.to_role)?;
entry.role = req.to_role.clone();
entry.updated_at = Some(now_epoch());
entry.updated_by = Some(auth.0.did.clone());
store_acl_entry(&acl, &entry).await?;
if is_privilege_reduction(
&prev_role,
&prev_contexts,
&entry.role,
&entry.allowed_contexts,
) {
let sessions = state.sessions_ks.clone();
let revoked = super::auth::revoke_sessions_for_did(&sessions, &did).await?;
info!(did = %did, revoked, "subject sessions revoked after ACL privilege reduction");
}
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&auth.0.did,
Some(&did),
AuditEvent::AclUpdated(AclChangeData {
did: did.clone(),
role: entry.role.to_string(),
contexts: entry.allowed_contexts.clone(),
expires_at: entry.expires_at.map(|e| e.to_string()),
}),
)
.await?;
}
info!(
did = %did,
from = %prev_role,
to = %entry.role,
reason = req.reason.as_deref().unwrap_or(""),
"ACL role changed",
);
Ok(Json(AclEntryResponse::from(entry)))
}
#[derive(Debug, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
#[into_params(parameter_in = Query)]
pub struct RevokeAclQuery {
pub scopes: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
impl RevokeAclQuery {
fn scopes_list(&self) -> Vec<String> {
self.scopes
.as_deref()
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
}
#[utoipa::path(
delete, path = "/acl/{did}", tag = "acl",
security(("bearer_jwt" = [])),
params(("did" = String, Path, description = "Subject DID")),
responses(
(status = 204, description = "ACL entry deleted"),
(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 delete_acl(
auth: AdminAuth,
State(state): State<AppState>,
Path(did): Path<String>,
Query(query): Query<RevokeAclQuery>,
) -> Result<StatusCode, AppError> {
if auth.0.did == did {
return Err(AppError::Conflict(
"cannot delete your own ACL entry".into(),
));
}
let acl = state.acl_ks.clone();
let entry = get_acl_entry(&acl, &did)
.await?
.ok_or_else(|| AppError::NotFound(format!("ACL entry not found for DID: {did}")))?;
if !is_acl_entry_visible(&auth.0, &as_vti_acl_entry(&entry)) {
return Err(AppError::NotFound(format!(
"ACL entry not found for DID: {did}"
)));
}
if entry.role == VtcRole::Admin && !caller_covers_admin_target(&auth.0, &entry) {
return Err(AppError::Forbidden(
"cannot delete an admin entry scoped outside your contexts".into(),
));
}
let reduce = query.scopes_list();
if !reduce.is_empty() {
let mut entry = entry;
let before = entry.allowed_contexts.len();
entry.allowed_contexts.retain(|s| !reduce.contains(s));
if entry.allowed_contexts.len() == before {
return Err(AppError::NotFound(format!(
"none of the requested scopes are held by {did}"
)));
}
match entry.act_scope() {
ActScope::All => {
return Err(AppError::Conflict(format!(
"revoking every scope of {did} would leave an unscoped \
(community-wide) entry; omit `scopes` to remove it instead"
)));
}
ActScope::None => {
return Err(AppError::Conflict(format!(
"revoking every scope of {did} would leave an entry that \
can act nowhere; omit `scopes` to remove it instead"
)));
}
ActScope::Contexts(_) => {}
}
entry.updated_at = Some(now_epoch());
entry.updated_by = Some(auth.0.did.clone());
store_acl_entry(&acl, &entry).await?;
let sessions = state.sessions_ks.clone();
let revoked = super::auth::revoke_sessions_for_did(&sessions, &did).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&auth.0.did,
Some(&did),
AuditEvent::AclUpdated(AclChangeData {
did: did.clone(),
role: entry.role.to_string(),
contexts: entry.allowed_contexts.clone(),
expires_at: entry.expires_at.map(|e| e.to_string()),
}),
)
.await?;
}
info!(
caller = %auth.0.did, did = %did, revoked,
remaining = entry.allowed_contexts.len(),
reason = query.reason.as_deref().unwrap_or(""),
"ACL scopes reduced",
);
return Ok(StatusCode::NO_CONTENT);
}
delete_acl_entry(&acl, &did).await?;
if let Some(writer) = state.audit_writer.as_ref() {
writer
.write(
&auth.0.did,
Some(&did),
AuditEvent::AclRevoked(AclRevokedData {
did: did.clone(),
prior_role: Some(entry.role.to_string()),
}),
)
.await?;
}
info!(
caller = %auth.0.did,
did = %did,
reason = query.reason.as_deref().unwrap_or(""),
"ACL entry revoked",
);
Ok(StatusCode::NO_CONTENT)
}
pub(crate) fn as_vti_acl_entry(e: &VtcAclEntry) -> vti_common::acl::AclEntry {
vti_common::acl::AclEntry::new(e.did.clone(), as_vti_role(&e.role), e.created_by.clone())
.with_label(e.label.clone())
.with_contexts(e.allowed_contexts.clone())
.with_created_at(e.created_at)
.with_expires_at(e.expires_at)
}
fn caller_covers_admin_target(caller: &AuthClaims, target: &VtcAclEntry) -> bool {
if caller.is_super_admin() {
return true;
}
match target.act_scope() {
ActScope::Contexts(cs) => cs.iter().all(|ctx| caller.has_context_access(ctx)),
ActScope::All | ActScope::None => false,
}
}
fn is_privilege_reduction(
prev_role: &VtcRole,
prev_contexts: &[String],
new_role: &VtcRole,
new_contexts: &[String],
) -> bool {
let lost_admin = *prev_role == VtcRole::Admin && *new_role != VtcRole::Admin;
let narrowed = if prev_contexts.is_empty() {
!new_contexts.is_empty()
} else {
!new_contexts.is_empty() && prev_contexts.iter().any(|c| !new_contexts.contains(c))
};
lost_admin || narrowed
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn claims(super_admin: bool, contexts: &[&str]) -> AuthClaims {
AuthClaims {
role: vti_common::acl::Role::Admin,
allowed_contexts: if super_admin {
vec![]
} else {
contexts.iter().map(|c| c.to_string()).collect()
},
..Default::default()
}
}
fn admin_entry(contexts: &[&str]) -> VtcAclEntry {
VtcAclEntry {
did: "did:key:zTarget".into(),
role: VtcRole::Admin,
label: None,
allowed_contexts: contexts.iter().map(|c| c.to_string()).collect(),
created_at: 0,
created_by: "did:key:zCreator".into(),
updated_at: None,
updated_by: None,
expires_at: None,
}
}
#[test]
fn super_admin_covers_any_admin_target() {
let sa = claims(true, &[]);
assert!(caller_covers_admin_target(
&sa,
&admin_entry(&["ctx-a", "ctx-b"])
));
assert!(caller_covers_admin_target(&sa, &admin_entry(&[]))); }
#[test]
fn context_admin_covers_only_targets_fully_within_its_scope() {
let ca = claims(false, &["ctx-a"]);
assert!(caller_covers_admin_target(&ca, &admin_entry(&["ctx-a"])));
assert!(!caller_covers_admin_target(
&ca,
&admin_entry(&["ctx-a", "ctx-b"])
));
assert!(!caller_covers_admin_target(&ca, &admin_entry(&[])));
}
#[test]
fn losing_admin_role_is_a_reduction() {
assert!(is_privilege_reduction(
&VtcRole::Admin,
&["ctx-a".into()],
&VtcRole::Member,
&["ctx-a".into()],
));
}
#[test]
fn narrowing_contexts_is_a_reduction() {
assert!(is_privilege_reduction(
&VtcRole::Admin,
&["ctx-a".into(), "ctx-b".into()],
&VtcRole::Admin,
&["ctx-a".into()],
));
assert!(is_privilege_reduction(
&VtcRole::Admin,
&[],
&VtcRole::Admin,
&["ctx-a".into()],
));
assert!(is_privilege_reduction(
&VtcRole::Admin,
&["ctx-a".into()],
&VtcRole::Admin,
&["ctx-b".into()],
));
}
#[test]
fn widening_or_lateral_change_is_not_a_reduction() {
assert!(!is_privilege_reduction(
&VtcRole::Admin,
&["ctx-a".into()],
&VtcRole::Admin,
&["ctx-a".into(), "ctx-b".into()],
));
assert!(!is_privilege_reduction(
&VtcRole::Admin,
&["ctx-a".into()],
&VtcRole::Admin,
&[],
));
assert!(!is_privilege_reduction(
&VtcRole::Member,
&["ctx-a".into()],
&VtcRole::Member,
&["ctx-a".into()],
));
}
#[test]
fn grant_request_parses_minimal_body() {
let body = json!({ "entry": { "subject": "did:key:zABC", "role": "admin" } });
let req: CreateAclRequest = serde_json::from_value(body).expect("minimal body");
assert_eq!(req.entry.subject, "did:key:zABC");
assert_eq!(req.entry.role, VtcRole::Admin);
assert_eq!(req.entry.label, None);
assert!(req.entry.scopes.is_empty(), "defaults to empty");
assert_eq!(req.entry.expires_at, None);
assert_eq!(req.reason, None);
}
#[test]
fn grant_request_parses_full_body() {
let body = json!({
"entry": {
"subject": "did:key:zABC",
"role": "moderator",
"label": "ops lead",
"scopes": ["ctx1", "ctx2"],
"expiresAt": "2027-01-15T00:00:00Z",
},
"reason": "quarterly review",
});
let req: CreateAclRequest = serde_json::from_value(body).expect("full body");
assert_eq!(req.entry.role, VtcRole::Moderator);
assert_eq!(req.entry.label.as_deref(), Some("ops lead"));
assert_eq!(req.entry.scopes, vec!["ctx1", "ctx2"]);
assert!(req.entry.expires_at.is_some());
assert_eq!(req.reason.as_deref(), Some("quarterly review"));
}
#[test]
fn grant_request_rejects_caller_supplied_provenance() {
for field in ["createdAt", "createdBy", "updatedAt", "updatedBy"] {
let body = json!({
"entry": { "subject": "did:key:zA", "role": "admin", field: "x" }
});
serde_json::from_value::<CreateAclRequest>(body)
.expect_err(&format!("{field} must not be accepted"));
}
}
#[test]
fn grant_request_rejects_unknown_role() {
let body = json!({ "entry": { "subject": "did:key:zA", "role": "godmode" } });
let err = serde_json::from_value::<CreateAclRequest>(body)
.expect_err("unknown role must not parse");
let msg = format!("{err}");
assert!(
msg.contains("godmode") || msg.contains("unknown"),
"got {msg}"
);
}
#[test]
fn change_role_request_requires_both_roles() {
let ok: UpdateAclRequest =
serde_json::from_value(json!({ "fromRole": "member", "toRole": "moderator" }))
.expect("both roles");
assert_eq!(ok.from_role, VtcRole::Member);
assert_eq!(ok.to_role, VtcRole::Moderator);
serde_json::from_value::<UpdateAclRequest>(json!({ "toRole": "admin" }))
.expect_err("fromRole is mandatory");
}
#[test]
fn create_acl_request_rejects_missing_required() {
let body = json!({ "role": "admin" });
serde_json::from_value::<CreateAclRequest>(body)
.expect_err("missing `did` must be rejected");
}
#[test]
fn change_role_request_rejects_the_pre_migration_body() {
for body in [
json!({}),
json!({ "role": "member" }),
json!({ "label": "ops", "allowed_contexts": ["ctx-a"] }),
] {
serde_json::from_value::<UpdateAclRequest>(body.clone())
.expect_err(&format!("legacy body must not parse: {body}"));
}
}
#[test]
fn list_acl_query_filters_are_optional() {
let q: ListAclQuery = serde_json::from_value(json!({})).unwrap();
assert!(q.scope.is_none());
assert!(q.role.is_none());
assert!(q.subject_prefix.is_none());
let q: ListAclQuery = serde_json::from_value(json!({ "scope": "app1" })).unwrap();
assert_eq!(q.scope.as_deref(), Some("app1"));
}
#[test]
fn acl_entry_response_serializes_with_stable_field_names() {
let entry = VtcAclEntry {
did: "did:key:zABC".into(),
role: VtcRole::Admin,
label: Some("test".into()),
allowed_contexts: vec!["ctx1".into()],
created_at: 1_700_000_000,
created_by: "did:key:zSetup".into(),
updated_at: None,
updated_by: None,
expires_at: Some(1_800_000_000),
};
let resp = AclEntryResponse::from(entry);
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["subject"], "did:key:zABC");
assert_eq!(json["role"], "admin");
assert_eq!(json["label"], "test");
assert_eq!(json["scopes"], json!(["ctx1"]));
assert_eq!(json["createdBy"], "did:key:zSetup");
assert_eq!(json["createdAt"], "2023-11-14T22:13:20+00:00");
assert_eq!(json["expiresAt"], "2027-01-15T08:00:00+00:00");
for old in [
"did",
"allowed_contexts",
"created_at",
"created_by",
"expires_at",
] {
assert!(
json.get(old).is_none(),
"{old} should not be emitted: {json}"
);
}
}
#[test]
fn acl_entry_response_omits_expires_at_when_permanent() {
let entry = VtcAclEntry {
did: "did:key:zPerm".into(),
role: VtcRole::Admin,
label: None,
allowed_contexts: vec![],
created_at: 1_700_000_000,
created_by: "did:key:zSetup".into(),
updated_at: None,
updated_by: None,
expires_at: None,
};
let resp = AclEntryResponse::from(entry);
let json = serde_json::to_value(&resp).unwrap();
assert!(
json.get("expiresAt").is_none() && json.get("expires_at").is_none(),
"permanent entries must omit expiresAt — got {json}"
);
assert_eq!(json["subject"], "did:key:zPerm");
assert!(json.get("did").is_none(), "did renamed to subject: {json}");
assert!(
json["createdAt"].as_str().unwrap().contains('T'),
"createdAt must be RFC3339, not an epoch int: {json}"
);
}
#[test]
fn acl_list_response_round_trips() {
let entries = vec![AclEntryResponse {
subject: "did:key:zA".into(),
role: VtcRole::Member,
label: None,
scopes: vec![],
created_at: epoch_to_rfc3339(0),
created_by: "did:key:zS".into(),
updated_at: None,
updated_by: None,
expires_at: None,
}];
let resp = AclListResponse {
entries,
truncated: false,
cursor: None,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains(r#""entries":"#), "got {json}");
assert!(json.contains(r#""role":"member""#));
assert!(json.contains(r#""truncated":false"#), "got {json}");
}
#[test]
fn custom_role_round_trip_through_request_body() {
let body = json!({
"entry": { "subject": "did:key:zEditor", "role": "custom:editor" },
});
let req: CreateAclRequest = serde_json::from_value(body).expect("custom role parses");
assert_eq!(req.entry.role, VtcRole::Custom("editor".into()));
let entry = VtcAclEntry {
did: req.entry.subject,
role: req.entry.role,
label: None,
allowed_contexts: vec![],
created_at: 0,
created_by: "did:key:zS".into(),
updated_at: None,
updated_by: None,
expires_at: None,
};
let resp = AclEntryResponse::from(entry);
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["role"], "custom:editor");
}
}