1use std::collections::BTreeSet;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::auth::extractor::AuthClaims;
7use crate::auth::step_up::StepUpMode;
8use crate::error::AppError;
9use crate::store::KeyspaceHandle;
10
11#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21#[serde(rename_all = "lowercase")]
22pub enum Role {
23 Admin,
24 Initiator,
25 Application,
26 Reader,
27 #[default]
33 Monitor,
34}
35
36impl fmt::Display for Role {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 Role::Admin => write!(f, "admin"),
40 Role::Initiator => write!(f, "initiator"),
41 Role::Application => write!(f, "application"),
42 Role::Reader => write!(f, "reader"),
43 Role::Monitor => write!(f, "monitor"),
44 }
45 }
46}
47
48impl Role {
49 pub fn parse(s: &str) -> Result<Self, AppError> {
51 match s {
52 "admin" => Ok(Role::Admin),
53 "initiator" => Ok(Role::Initiator),
54 "application" => Ok(Role::Application),
55 "reader" => Ok(Role::Reader),
56 "monitor" => Ok(Role::Monitor),
57 _ => Err(AppError::Internal(format!("unknown role: {s}"))),
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75#[serde(tag = "kind", rename_all = "kebab-case")]
76pub enum ConsumerKind {
77 #[serde(rename_all = "kebab-case")]
78 Companion { form_factor: CompanionFormFactor },
79 #[serde(rename_all = "camelCase")]
80 Service {
81 #[serde(rename = "serviceKind")]
82 service_kind: ServiceKind,
83 },
84}
85
86impl Default for ConsumerKind {
87 fn default() -> Self {
88 ConsumerKind::Service {
89 service_kind: ServiceKind::Daemon,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "kebab-case")]
96pub enum CompanionFormFactor {
97 Browser,
98 Mobile,
99 Desktop,
100}
101
102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "kebab-case")]
104pub enum ServiceKind {
105 Mediator,
106 AiAgent,
107 Daemon,
108}
109
110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
122#[serde(rename_all = "kebab-case")]
123pub enum Capability {
124 VaultRead,
125 VaultWrite,
126 ProxyLogin,
127 FillRelease,
128 PolicyAdmin,
129 DeviceAdmin,
130 Sign,
131 KeyMint,
132 SignTrustTask,
138 CredentialWrite,
146}
147
148pub fn role_has_capability(role: &Role, cap: Capability) -> bool {
153 derived_capabilities_for_role(role).contains(&cap)
154}
155
156pub fn derived_capabilities_for_role(role: &Role) -> Vec<Capability> {
161 match role {
162 Role::Admin => vec![
163 Capability::VaultRead,
164 Capability::VaultWrite,
165 Capability::CredentialWrite,
166 Capability::ProxyLogin,
167 Capability::FillRelease,
168 Capability::PolicyAdmin,
169 Capability::DeviceAdmin,
170 Capability::Sign,
171 Capability::SignTrustTask,
172 Capability::KeyMint,
173 ],
174 Role::Initiator => vec![
175 Capability::VaultRead,
176 Capability::VaultWrite,
177 Capability::CredentialWrite,
178 Capability::ProxyLogin,
179 Capability::FillRelease,
180 Capability::DeviceAdmin,
181 Capability::Sign,
182 Capability::SignTrustTask,
183 Capability::KeyMint,
184 ],
185 Role::Application => vec![
186 Capability::VaultRead,
187 Capability::ProxyLogin,
188 Capability::FillRelease,
189 Capability::Sign,
190 Capability::SignTrustTask,
191 ],
192 Role::Reader => vec![Capability::VaultRead],
193 Role::Monitor => vec![],
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206#[serde(rename_all = "camelCase")]
207pub struct WakeChannel {
208 pub gateway: String,
211 pub handle: String,
214 #[serde(default)]
218 pub allowed_triggers: Vec<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
228#[serde(rename_all = "camelCase")]
229pub struct DeviceBinding {
230 pub device_id: String,
231 pub display_name: String,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub platform: Option<String>,
234 pub registered_at: String,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub last_seen_at: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub disabled_at: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub wiped_at: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub hpke_public_key: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub wake: Option<WakeChannel>,
253}
254
255impl DeviceBinding {
256 pub fn push_capable(&self) -> bool {
260 self.wake.is_some() && self.disabled_at.is_none() && self.wiped_at.is_none()
261 }
262}
263
264pub use vta_sdk::acl::{ActScope, ApproveScope, ContextDirection};
273
274pub fn act_scope_for(role: &Role, allowed_contexts: &[String]) -> ActScope {
293 match (role, allowed_contexts) {
294 (_, cs) if !cs.is_empty() => ActScope::Contexts(cs.to_vec()),
295 (Role::Admin, _) => ActScope::All,
296 _ => ActScope::None,
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
315pub enum KeyScope {
316 AllInScope,
320 Keys(BTreeSet<String>),
325}
326
327impl KeyScope {
328 pub fn allows(&self, key_id: &str) -> bool {
334 match self {
335 KeyScope::AllInScope => true,
336 KeyScope::Keys(keys) => keys.contains(key_id),
337 }
338 }
339}
340
341pub fn key_scope_for(allowed_keys: Option<&BTreeSet<String>>) -> KeyScope {
355 match allowed_keys {
356 None => KeyScope::AllInScope,
357 Some(keys) => KeyScope::Keys(keys.clone()),
358 }
359}
360
361pub fn validate_approve_scope_grant(
368 caller: &AuthClaims,
369 scope: &ApproveScope,
370) -> Result<(), AppError> {
371 match scope {
372 ApproveScope::None => Ok(()),
373 ApproveScope::All => {
374 if caller.is_super_admin() {
375 Ok(())
376 } else {
377 Err(AppError::Forbidden(
378 "only super admin can grant approve-all authority".into(),
379 ))
380 }
381 }
382 ApproveScope::Contexts(cs) => {
383 if cs.is_empty() {
384 return Err(AppError::Forbidden(
385 "approve scope must name at least one context (or use 'all')".into(),
386 ));
387 }
388 for c in cs {
391 crate::context_path::validate_context_path(c)?;
392 }
393 for c in cs {
394 caller.require_context(c)?;
395 }
396 Ok(())
397 }
398 }
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct AclEntry {
404 pub did: String,
405 pub role: Role,
406 pub label: Option<String>,
407 #[serde(default)]
408 pub allowed_contexts: Vec<String>,
409 pub created_at: u64,
410 pub created_by: String,
411 #[serde(default)]
416 pub expires_at: Option<u64>,
417 #[serde(default)]
421 pub kind: ConsumerKind,
422 #[serde(default)]
426 pub capabilities: Vec<Capability>,
427 #[serde(default)]
431 pub device: Option<DeviceBinding>,
432 #[serde(default)]
442 pub version: u32,
443 #[serde(default)]
453 pub step_up_approver: Option<String>,
454 #[serde(default)]
464 pub step_up_require: Option<StepUpMode>,
465 #[serde(default)]
472 pub approve_scope: ApproveScope,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub allowed_keys: Option<BTreeSet<String>>,
483}
484
485impl AclEntry {
486 pub fn new(did: impl Into<String>, role: Role, created_by: impl Into<String>) -> Self {
495 Self {
496 did: did.into(),
497 role,
498 label: None,
499 allowed_contexts: Vec::new(),
500 created_at: crate::auth::session::now_epoch(),
501 created_by: created_by.into(),
502 expires_at: None,
503 kind: ConsumerKind::default(),
504 capabilities: Vec::new(),
505 device: None,
506 version: 0,
507 step_up_approver: None,
508 step_up_require: None,
509 approve_scope: ApproveScope::None,
510 allowed_keys: None,
511 }
512 }
513
514 pub fn with_created_at(mut self, created_at: u64) -> Self {
517 self.created_at = created_at;
518 self
519 }
520
521 pub fn with_label(mut self, label: Option<String>) -> Self {
523 self.label = label;
524 self
525 }
526
527 pub fn with_contexts(mut self, allowed_contexts: Vec<String>) -> Self {
529 self.allowed_contexts = allowed_contexts;
530 self
531 }
532
533 pub fn with_expires_at(mut self, expires_at: Option<u64>) -> Self {
535 self.expires_at = expires_at;
536 self
537 }
538
539 pub fn with_kind(mut self, kind: ConsumerKind) -> Self {
541 self.kind = kind;
542 self
543 }
544
545 pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
547 self.capabilities = capabilities;
548 self
549 }
550
551 pub fn with_device(mut self, device: Option<DeviceBinding>) -> Self {
553 self.device = device;
554 self
555 }
556
557 pub fn with_step_up_approver(mut self, approver: Option<String>) -> Self {
559 self.step_up_approver = approver;
560 self
561 }
562
563 pub fn with_step_up_require(mut self, require: Option<StepUpMode>) -> Self {
565 self.step_up_require = require;
566 self
567 }
568
569 pub fn with_approve_scope(mut self, approve_scope: ApproveScope) -> Self {
572 self.approve_scope = approve_scope;
573 self
574 }
575
576 pub fn with_allowed_keys(mut self, allowed_keys: Option<BTreeSet<String>>) -> Self {
579 self.allowed_keys = allowed_keys;
580 self
581 }
582
583 pub fn key_scope(&self) -> KeyScope {
588 key_scope_for(self.allowed_keys.as_ref())
589 }
590
591 pub fn is_admin(&self) -> bool {
593 matches!(self.role, Role::Admin)
594 }
595
596 pub fn act_scope(&self) -> ActScope {
600 act_scope_for(&self.role, &self.allowed_contexts)
601 }
602
603 pub fn can_act_in(&self, context_id: &str) -> bool {
605 self.act_scope().covers(context_id)
606 }
607
608 pub fn is_super_admin(&self) -> bool {
611 self.is_admin() && self.act_scope().is_unrestricted()
612 }
613
614 pub fn with_version(mut self, version: u32) -> Self {
616 self.version = version;
617 self
618 }
619
620 pub fn is_expired(&self, now_unix: u64) -> bool {
623 match self.expires_at {
624 Some(deadline) => now_unix >= deadline,
625 None => false,
626 }
627 }
628
629 pub fn etag(&self) -> String {
641 use std::collections::hash_map::DefaultHasher;
642 use std::hash::{Hash, Hasher};
643 let mut h = DefaultHasher::new();
644 self.did.hash(&mut h);
645 format!("W/\"{:016x}:{}\"", h.finish(), self.version)
646 }
647}
648
649fn acl_key(did: &str) -> String {
650 format!("acl:{did}")
651}
652
653pub async fn get_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<Option<AclEntry>, AppError> {
655 acl.get(acl_key(did)).await
656}
657
658pub async fn store_acl_entry(acl: &KeyspaceHandle, entry: &AclEntry) -> Result<(), AppError> {
666 acl.insert(acl_key(&entry.did), entry).await?;
667 crate::integrity::reseal_if_active().await
670}
671
672pub async fn update_acl_entry_versioned(
688 acl: &KeyspaceHandle,
689 mut new_entry: AclEntry,
690 expected_version: u32,
691) -> Result<u32, AppError> {
692 let key = acl_key(&new_entry.did);
693 let current: Option<AclEntry> = acl.get(key.clone()).await?;
694 let stored_version = current.as_ref().map(|e| e.version).unwrap_or(0);
695 if stored_version != expected_version {
696 return Err(AppError::Conflict(format!(
697 "ACL entry for {} has moved ahead (expected v{}, found v{}); re-read and retry",
698 new_entry.did, expected_version, stored_version,
699 )));
700 }
701 new_entry.version = expected_version + 1;
702 acl.insert(key, &new_entry).await?;
703 crate::integrity::reseal_if_active().await?; Ok(new_entry.version)
705}
706
707pub async fn delete_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
709 acl.remove(acl_key(did)).await?;
710 crate::integrity::reseal_if_active().await
713}
714
715pub async fn list_acl_entries(acl: &KeyspaceHandle) -> Result<Vec<AclEntry>, AppError> {
724 let raw = acl.prefix_iter_raw("acl:").await?;
725 let mut entries = Vec::with_capacity(raw.len());
726 let mut skipped = 0usize;
727 for (key, value) in raw {
728 match serde_json::from_slice::<AclEntry>(&value) {
729 Ok(entry) => entries.push(entry),
730 Err(e) => {
731 skipped += 1;
732 tracing::warn!(
733 key = %String::from_utf8_lossy(&key),
734 error = %e,
735 "skipping undeserializable ACL row in list_acl_entries"
736 );
737 }
738 }
739 }
740 if skipped > 0 {
741 tracing::warn!(skipped, "list_acl_entries skipped corrupt rows");
742 }
743 Ok(entries)
744}
745
746fn now_epoch() -> u64 {
747 std::time::SystemTime::now()
748 .duration_since(std::time::UNIX_EPOCH)
749 .map(|d| d.as_secs())
750 .unwrap_or(0)
751}
752
753pub async fn check_acl(acl: &KeyspaceHandle, did: &str) -> Result<Role, AppError> {
757 match get_acl_entry(acl, did).await? {
758 Some(entry) if entry.is_expired(now_epoch()) => {
759 Err(AppError::Forbidden(format!("ACL entry expired: {did}")))
760 }
761 Some(entry) => Ok(entry.role),
762 None => Err(AppError::Forbidden(format!("DID not in ACL: {did}"))),
763 }
764}
765
766pub async fn check_acl_full(
770 acl: &KeyspaceHandle,
771 did: &str,
772) -> Result<(Role, Vec<String>), AppError> {
773 match get_acl_entry(acl, did).await? {
774 Some(entry) if entry.is_expired(now_epoch()) => {
775 Err(AppError::Forbidden(format!("ACL entry expired: {did}")))
776 }
777 Some(entry) => Ok((entry.role, entry.allowed_contexts)),
778 None => Err(AppError::Forbidden(format!("DID not in ACL: {did}"))),
779 }
780}
781
782pub fn validate_role_assignment(caller: &AuthClaims, target_role: &Role) -> Result<(), AppError> {
787 if matches!(
788 caller.role,
789 Role::Monitor | Role::Reader | Role::Application
790 ) {
791 return Err(AppError::Forbidden(
792 "insufficient role to assign roles".into(),
793 ));
794 }
795 if *target_role == Role::Admin && caller.role != Role::Admin {
796 return Err(AppError::Forbidden(
797 "only admins can assign the admin role".into(),
798 ));
799 }
800 Ok(())
801}
802
803pub fn validate_acl_modification(
811 caller: &AuthClaims,
812 target_role: &Role,
813 target_contexts: &[String],
814) -> Result<(), AppError> {
815 for ctx in target_contexts {
823 crate::context_path::validate_context_path(ctx)?;
824 }
825 if caller.is_super_admin() {
826 return Ok(());
827 }
828 match act_scope_for(target_role, target_contexts) {
836 ActScope::All => Err(AppError::Forbidden(
839 "only super admin can create an unrestricted (super-admin) account".into(),
840 )),
841 ActScope::None => Ok(()),
847 ActScope::Contexts(cs) => {
849 for ctx in &cs {
850 caller.require_context(ctx)?;
851 }
852 Ok(())
853 }
854 }
855}
856
857pub fn delegated_any_approver_covers(approver: &AclEntry, subject: &AclEntry) -> bool {
871 match &approver.approve_scope {
877 ApproveScope::All => return true,
878 ApproveScope::Contexts(_) => {
879 if let ActScope::Contexts(subject_ctxs) = subject.act_scope()
883 && subject_ctxs
884 .iter()
885 .all(|c| approver.approve_scope.covers(c))
886 {
887 return true;
888 }
889 }
891 ApproveScope::None => {}
892 }
893
894 if !approver.is_admin() {
896 return false;
897 }
898 match approver.act_scope() {
899 ActScope::All => true,
901 approver_scope @ ActScope::Contexts(_) => match subject.act_scope() {
916 ActScope::Contexts(subject_ctxs) => {
917 subject_ctxs.iter().all(|c| approver_scope.covers(c))
918 }
919 _ => false,
920 },
921 ActScope::None => false,
923 }
924}
925
926pub fn acl_entry_can_act_in(entry: &AclEntry, context_id: &str) -> bool {
943 entry.act_scope().covers(context_id)
944}
945
946pub fn acl_entry_acts_within(entry: &AclEntry, context_id: &str) -> bool {
954 entry.act_scope().acts_within(context_id)
955}
956
957pub fn acl_entry_matches_context(
966 entry: &AclEntry,
967 context_id: &str,
968 direction: ContextDirection,
969) -> bool {
970 entry.act_scope().matches_context(context_id, direction)
971}
972
973pub fn is_acl_entry_visible(caller: &AuthClaims, entry: &AclEntry) -> bool {
986 if caller.is_super_admin() {
987 return true;
988 }
989 entry
990 .act_scope()
991 .named_contexts()
992 .iter()
993 .any(|ctx| caller.has_context_access(ctx))
994}
995
996pub fn is_acl_entry_auditable(caller: &AuthClaims, entry: &AclEntry) -> bool {
1015 if is_acl_entry_visible(caller, entry) {
1016 return true;
1017 }
1018 match &entry.approve_scope {
1019 ApproveScope::All => true,
1021 ApproveScope::Contexts(cs) => cs.iter().any(|ctx| caller.has_context_access(ctx)),
1022 ApproveScope::None => false,
1023 }
1024}
1025
1026#[cfg(test)]
1027mod delegated_any_tests {
1028 use super::*;
1029
1030 fn admin(contexts: &[&str]) -> AclEntry {
1031 AclEntry::new("did:key:zApprover", Role::Admin, "did:key:zCreator")
1032 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
1033 }
1034 fn subject(role: Role, contexts: &[&str]) -> AclEntry {
1035 AclEntry::new("did:key:zSubject", role, "did:key:zCreator")
1036 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
1037 }
1038
1039 #[test]
1046 fn context_admin_covers_a_subject_in_its_subtree() {
1047 let approver = admin(&["acme"]);
1048 assert!(
1049 delegated_any_approver_covers(&approver, &subject(Role::Reader, &["acme/eng"])),
1050 "an admin of `acme` administers `acme/eng`, so it may ratify for it"
1051 );
1052 assert!(
1053 delegated_any_approver_covers(
1054 &approver,
1055 &subject(Role::Reader, &["acme/eng", "acme/sales"])
1056 ),
1057 "…and for a subject scoped to several of its descendants"
1058 );
1059 }
1060
1061 #[test]
1064 fn context_admin_does_not_cover_a_prefix_sibling() {
1065 let approver = admin(&["acme"]);
1066 assert!(
1067 !delegated_any_approver_covers(&approver, &subject(Role::Reader, &["acme-evil"])),
1068 "`acme` must not cover `acme-evil` — segments, not string prefixes"
1069 );
1070 }
1071
1072 #[test]
1075 fn context_admin_refuses_a_subject_partly_outside_its_subtree() {
1076 let approver = admin(&["acme"]);
1077 assert!(
1078 !delegated_any_approver_covers(
1079 &approver,
1080 &subject(Role::Reader, &["acme/eng", "other"])
1081 ),
1082 "every subject context must fall within the approver's"
1083 );
1084 }
1085
1086 #[test]
1089 fn flat_contexts_are_unchanged_by_ancestry() {
1090 let approver = admin(&["ctx-a"]);
1091 assert!(delegated_any_approver_covers(
1092 &approver,
1093 &subject(Role::Reader, &["ctx-a"])
1094 ));
1095 assert!(!delegated_any_approver_covers(
1096 &approver,
1097 &subject(Role::Reader, &["ctx-b"])
1098 ));
1099 }
1100
1101 #[test]
1102 fn super_admin_covers_any_subject() {
1103 let sa = admin(&[]); assert!(delegated_any_approver_covers(
1105 &sa,
1106 &subject(Role::Admin, &["ctx-a"])
1107 ));
1108 assert!(delegated_any_approver_covers(
1109 &sa,
1110 &subject(Role::Reader, &[])
1111 )); assert!(delegated_any_approver_covers(
1113 &sa,
1114 &subject(Role::Application, &["ctx-a", "ctx-b"])
1115 ));
1116 }
1117
1118 #[test]
1119 fn context_admin_covers_only_within_its_contexts() {
1120 let ca = admin(&["ctx-a", "ctx-b"]);
1121 assert!(delegated_any_approver_covers(
1123 &ca,
1124 &subject(Role::Reader, &["ctx-a"])
1125 ));
1126 assert!(delegated_any_approver_covers(
1127 &ca,
1128 &subject(Role::Reader, &["ctx-a", "ctx-b"])
1129 ));
1130 assert!(!delegated_any_approver_covers(
1132 &ca,
1133 &subject(Role::Reader, &["ctx-c"])
1134 ));
1135 assert!(!delegated_any_approver_covers(
1136 &ca,
1137 &subject(Role::Reader, &["ctx-a", "ctx-c"])
1138 ));
1139 }
1140
1141 #[test]
1142 fn context_admin_never_covers_a_global_subject() {
1143 let ca = admin(&["ctx-a"]);
1146 assert!(!delegated_any_approver_covers(
1147 &ca,
1148 &subject(Role::Admin, &[])
1149 ));
1150 }
1151
1152 #[test]
1153 fn non_admins_never_qualify() {
1154 for role in [
1155 Role::Initiator,
1156 Role::Application,
1157 Role::Reader,
1158 Role::Monitor,
1159 ] {
1160 let not_admin = AclEntry::new("did:key:zX", role, "did:key:zC");
1161 assert!(!delegated_any_approver_covers(
1162 ¬_admin,
1163 &subject(Role::Reader, &["ctx-a"])
1164 ));
1165 }
1166 }
1167
1168 fn pure_approver(scope: ApproveScope) -> AclEntry {
1173 AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zCreator")
1174 .with_approve_scope(scope)
1175 }
1176
1177 #[test]
1178 fn approve_scope_covers_semantics() {
1179 assert!(!ApproveScope::None.covers("ctx-a"));
1180 assert!(ApproveScope::All.covers("anything"));
1181 let scoped = ApproveScope::Contexts(vec!["ctx-a".into()]);
1182 assert!(scoped.covers("ctx-a"));
1183 assert!(!scoped.covers("ctx-b"));
1184 }
1185
1186 #[test]
1187 fn approve_all_confers_for_any_subject_without_any_admin_authority() {
1188 let approver = pure_approver(ApproveScope::All);
1191 assert!(
1192 !approver.is_admin(),
1193 "the approver holds no admin authority"
1194 );
1195 assert!(delegated_any_approver_covers(
1196 &approver,
1197 &subject(Role::Reader, &["ctx-a"])
1198 ));
1199 assert!(delegated_any_approver_covers(
1200 &approver,
1201 &subject(Role::Admin, &[])
1202 ));
1203 }
1204
1205 #[test]
1206 fn scoped_approve_authority_covers_only_within_scope() {
1207 let approver = pure_approver(ApproveScope::Contexts(vec!["ctx-a".into()]));
1208 assert!(delegated_any_approver_covers(
1209 &approver,
1210 &subject(Role::Reader, &["ctx-a"])
1211 ));
1212 assert!(!delegated_any_approver_covers(
1213 &approver,
1214 &subject(Role::Reader, &["ctx-b"])
1215 ));
1216 assert!(!delegated_any_approver_covers(
1218 &approver,
1219 &subject(Role::Admin, &[])
1220 ));
1221 }
1222
1223 #[test]
1224 fn no_approve_scope_and_no_admin_confers_nothing() {
1225 let reader = pure_approver(ApproveScope::None);
1226 assert!(!delegated_any_approver_covers(
1227 &reader,
1228 &subject(Role::Reader, &["ctx-a"])
1229 ));
1230 }
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use super::*;
1236 use crate::config::StoreConfig;
1237 use crate::store::Store;
1238
1239 fn temp_store() -> (Store, tempfile::TempDir) {
1242 let dir = tempfile::tempdir().expect("tempdir");
1243 let config = StoreConfig {
1244 data_dir: dir.path().to_path_buf(),
1245 };
1246 let store = Store::open(&config).expect("open store");
1247 (store, dir)
1248 }
1249
1250 fn sample_entry(did: &str, role: Role) -> AclEntry {
1251 AclEntry::new(did, role, "did:key:zSetup").with_label(Some(format!("test-{did}")))
1252 }
1253
1254 #[tokio::test]
1255 async fn list_acl_entries_skips_corrupt_rows() {
1256 let (store, _dir) = temp_store();
1260 let ks = store.keyspace("acl").unwrap();
1261
1262 store_acl_entry(&ks, &sample_entry("did:key:zAlice", Role::Admin))
1263 .await
1264 .unwrap();
1265 ks.insert_raw("acl:did:key:zCorrupt", b"{not valid json".to_vec())
1267 .await
1268 .unwrap();
1269 store_acl_entry(&ks, &sample_entry("did:key:zBob", Role::Reader))
1270 .await
1271 .unwrap();
1272
1273 let entries = list_acl_entries(&ks).await.expect("listing must not abort");
1274 let dids: Vec<&str> = entries.iter().map(|e| e.did.as_str()).collect();
1275 assert!(dids.contains(&"did:key:zAlice"));
1276 assert!(dids.contains(&"did:key:zBob"));
1277 assert_eq!(entries.len(), 2, "the corrupt row is skipped, not surfaced");
1278 }
1279
1280 fn scoped_entry(did: &str, role: Role, contexts: &[&str]) -> AclEntry {
1281 AclEntry::new(did, role, "did:key:zSetup")
1282 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
1283 }
1284
1285 fn super_admin_claims() -> AuthClaims {
1286 AuthClaims {
1287 did: "did:key:zSuperAdmin".into(),
1288 role: Role::Admin,
1289 allowed_contexts: vec![],
1290 session_id: "test-session".into(),
1291 access_expires_at: 0,
1292 amr: Vec::new(),
1293 acr: String::new(),
1294 }
1295 }
1296
1297 fn context_admin_claims(contexts: &[&str]) -> AuthClaims {
1298 AuthClaims {
1299 did: "did:key:zCtxAdmin".into(),
1300 role: Role::Admin,
1301 allowed_contexts: contexts.iter().map(|s| s.to_string()).collect(),
1302 session_id: "test-session".into(),
1303 access_expires_at: 0,
1304 amr: Vec::new(),
1305 acr: String::new(),
1306 }
1307 }
1308
1309 #[test]
1312 fn role_parse_accepts_canonical_lowercase() {
1313 assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
1314 assert_eq!(Role::parse("initiator").unwrap(), Role::Initiator);
1315 assert_eq!(Role::parse("application").unwrap(), Role::Application);
1316 assert_eq!(Role::parse("reader").unwrap(), Role::Reader);
1317 assert_eq!(Role::parse("monitor").unwrap(), Role::Monitor);
1318 }
1319
1320 #[test]
1321 fn role_parse_rejects_unknown() {
1322 let err = Role::parse("godmode").expect_err("unknown role must error");
1323 assert!(format!("{err:?}").contains("godmode"), "got {err:?}");
1324 }
1325
1326 fn sample_binding() -> DeviceBinding {
1329 DeviceBinding {
1330 device_id: "dev-1".into(),
1331 display_name: "Glenn's iPhone".into(),
1332 platform: Some("iOS 19".into()),
1333 registered_at: "2026-06-02T00:00:00Z".into(),
1334 last_seen_at: None,
1335 disabled_at: None,
1336 wiped_at: None,
1337 hpke_public_key: None,
1338 wake: None,
1339 }
1340 }
1341
1342 #[test]
1343 fn wake_channel_round_trips_camel_case() {
1344 let mut b = sample_binding();
1345 b.wake = Some(WakeChannel {
1346 gateway: "https://gw.example".into(),
1347 handle: "z6MkOpaque".into(),
1348 allowed_triggers: vec!["did:web:mediator".into(), "did:web:vta".into()],
1349 });
1350 let json = serde_json::to_string(&b).unwrap();
1351 assert!(json.contains("\"wake\""), "{json}");
1353 assert!(json.contains("\"allowedTriggers\""), "{json}");
1354 let back: DeviceBinding = serde_json::from_str(&json).unwrap();
1355 assert_eq!(b, back);
1356 }
1357
1358 #[test]
1359 fn legacy_row_without_wake_deserialises_to_none() {
1360 let legacy =
1362 r#"{"deviceId":"dev-1","displayName":"old","registeredAt":"2026-01-01T00:00:00Z"}"#;
1363 let b: DeviceBinding = serde_json::from_str(legacy).unwrap();
1364 assert!(b.wake.is_none());
1365 assert!(!b.push_capable());
1366 }
1367
1368 #[test]
1369 fn push_capable_requires_wake_and_active_device() {
1370 let mut b = sample_binding();
1371 assert!(!b.push_capable(), "no wake channel → not push-capable");
1372
1373 b.wake = Some(WakeChannel {
1374 gateway: "did:web:gw".into(),
1375 handle: "h".into(),
1376 allowed_triggers: vec!["did:web:vta".into()],
1377 });
1378 assert!(b.push_capable(), "wake set + active → push-capable");
1379
1380 b.disabled_at = Some("2026-06-02T01:00:00Z".into());
1381 assert!(!b.push_capable(), "disabled device is not push-capable");
1382
1383 b.disabled_at = None;
1384 b.wiped_at = Some("2026-06-02T02:00:00Z".into());
1385 assert!(!b.push_capable(), "wiped device is not push-capable");
1386 }
1387
1388 #[test]
1389 fn role_parse_rejects_case_variation() {
1390 assert!(Role::parse("Admin").is_err(), "case-sensitive parse");
1393 assert!(Role::parse("ADMIN").is_err());
1394 }
1395
1396 #[test]
1397 fn role_display_round_trips_with_parse() {
1398 for role in [
1399 Role::Admin,
1400 Role::Initiator,
1401 Role::Application,
1402 Role::Reader,
1403 Role::Monitor,
1404 ] {
1405 let s = format!("{role}");
1406 assert_eq!(Role::parse(&s).unwrap(), role, "display->parse cycle");
1407 }
1408 }
1409
1410 #[test]
1416 fn key_scope_none_is_unrestricted_and_empty_set_allows_nothing() {
1417 let unfiltered = sample_entry("did:key:zA", Role::Application);
1418 assert_eq!(unfiltered.key_scope(), KeyScope::AllInScope);
1419 assert!(unfiltered.key_scope().allows("any-key"));
1420
1421 let no_keys =
1422 sample_entry("did:key:zB", Role::Application).with_allowed_keys(Some(BTreeSet::new()));
1423 assert_eq!(no_keys.key_scope(), KeyScope::Keys(BTreeSet::new()));
1424 assert!(
1425 !no_keys.key_scope().allows("any-key"),
1426 "an empty filter authorizes NO keys — it is never a wildcard"
1427 );
1428 }
1429
1430 #[test]
1431 fn key_scope_filters_by_exact_key_id() {
1432 let entry = sample_entry("did:key:zA", Role::Application)
1433 .with_allowed_keys(Some(["key-1".to_string()].into_iter().collect()));
1434 assert!(entry.key_scope().allows("key-1"));
1435 assert!(!entry.key_scope().allows("key-2"));
1436 assert!(
1437 !entry.key_scope().allows("key-1x"),
1438 "exact match, not prefix"
1439 );
1440 }
1441
1442 #[test]
1446 fn legacy_row_without_allowed_keys_deserialises_to_none() {
1447 let legacy = serde_json::json!({
1448 "did": "did:key:zOld",
1449 "role": "application",
1450 "label": null,
1451 "allowed_contexts": ["ctx-a"],
1452 "created_at": 0,
1453 "created_by": "did:key:zSetup"
1454 });
1455 let entry: AclEntry = serde_json::from_value(legacy).unwrap();
1456 assert!(entry.allowed_keys.is_none());
1457 assert_eq!(entry.key_scope(), KeyScope::AllInScope);
1458 }
1459
1460 #[test]
1464 fn empty_allowed_keys_survives_serde_round_trip() {
1465 let entry =
1466 sample_entry("did:key:zA", Role::Application).with_allowed_keys(Some(BTreeSet::new()));
1467 let json = serde_json::to_string(&entry).unwrap();
1468 assert!(json.contains("\"allowed_keys\":[]"), "{json}");
1469 let back: AclEntry = serde_json::from_str(&json).unwrap();
1470 assert_eq!(back.allowed_keys, Some(BTreeSet::new()));
1471 assert!(!back.key_scope().allows("k"));
1472
1473 let unfiltered = sample_entry("did:key:zB", Role::Application);
1475 let json = serde_json::to_string(&unfiltered).unwrap();
1476 assert!(!json.contains("allowed_keys"), "{json}");
1477 }
1478
1479 #[test]
1482 fn entry_without_expiry_never_expires() {
1483 let entry = sample_entry("did:key:zA", Role::Admin);
1484 assert!(entry.expires_at.is_none());
1485 assert!(
1486 !entry.is_expired(u64::MAX),
1487 "permanent entries never expire"
1488 );
1489 }
1490
1491 #[test]
1492 fn entry_with_future_expiry_is_not_expired() {
1493 let mut entry = sample_entry("did:key:zA", Role::Admin);
1494 entry.expires_at = Some(now_epoch() + 3600);
1495 assert!(!entry.is_expired(now_epoch()));
1496 }
1497
1498 #[test]
1499 fn entry_with_past_expiry_is_expired() {
1500 let mut entry = sample_entry("did:key:zA", Role::Admin);
1501 entry.expires_at = Some(now_epoch().saturating_sub(1));
1502 assert!(entry.is_expired(now_epoch()));
1503 }
1504
1505 #[test]
1506 fn entry_with_exact_expiry_boundary_is_expired() {
1507 let mut entry = sample_entry("did:key:zA", Role::Admin);
1511 let now = now_epoch();
1512 entry.expires_at = Some(now);
1513 assert!(entry.is_expired(now), "now == deadline counts as expired");
1514 }
1515
1516 #[tokio::test]
1519 async fn crud_round_trip() {
1520 let (store, _dir) = temp_store();
1521 let acl = store.keyspace("acl").unwrap();
1522
1523 let entry = sample_entry("did:key:zAbc", Role::Admin);
1524 store_acl_entry(&acl, &entry).await.unwrap();
1525
1526 let got = get_acl_entry(&acl, "did:key:zAbc")
1527 .await
1528 .unwrap()
1529 .expect("entry should exist");
1530 assert_eq!(got.did, entry.did);
1531 assert_eq!(got.role, Role::Admin);
1532
1533 delete_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1534 let gone = get_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1535 assert!(gone.is_none(), "deleted entry must be gone");
1536 }
1537
1538 #[tokio::test]
1539 async fn list_returns_every_entry() {
1540 let (store, _dir) = temp_store();
1541 let acl = store.keyspace("acl").unwrap();
1542
1543 for did in ["did:key:zA", "did:key:zB", "did:key:zC"] {
1544 store_acl_entry(&acl, &sample_entry(did, Role::Reader))
1545 .await
1546 .unwrap();
1547 }
1548
1549 let entries = list_acl_entries(&acl).await.unwrap();
1550 assert_eq!(entries.len(), 3);
1551 let dids: std::collections::HashSet<_> = entries.iter().map(|e| e.did.as_str()).collect();
1552 assert!(dids.contains("did:key:zA"));
1553 assert!(dids.contains("did:key:zB"));
1554 assert!(dids.contains("did:key:zC"));
1555 }
1556
1557 #[tokio::test]
1560 async fn check_acl_returns_role_for_present_did() {
1561 let (store, _dir) = temp_store();
1562 let acl = store.keyspace("acl").unwrap();
1563 store_acl_entry(&acl, &sample_entry("did:key:zA", Role::Initiator))
1564 .await
1565 .unwrap();
1566
1567 let role = check_acl(&acl, "did:key:zA").await.unwrap();
1568 assert_eq!(role, Role::Initiator);
1569 }
1570
1571 #[tokio::test]
1572 async fn check_acl_rejects_missing_did_as_forbidden() {
1573 let (store, _dir) = temp_store();
1574 let acl = store.keyspace("acl").unwrap();
1575
1576 let err = check_acl(&acl, "did:key:zUnknown")
1577 .await
1578 .expect_err("missing DID must be rejected");
1579 assert!(
1580 matches!(err, AppError::Forbidden(_)),
1581 "got {err:?}; expected Forbidden so the handler emits 403"
1582 );
1583 }
1584
1585 #[tokio::test]
1586 async fn check_acl_rejects_expired_entry() {
1587 let (store, _dir) = temp_store();
1588 let acl = store.keyspace("acl").unwrap();
1589
1590 let mut entry = sample_entry("did:key:zExpired", Role::Admin);
1591 entry.expires_at = Some(now_epoch().saturating_sub(10));
1592 store_acl_entry(&acl, &entry).await.unwrap();
1593
1594 let err = check_acl(&acl, "did:key:zExpired")
1595 .await
1596 .expect_err("expired entry must be rejected");
1597 let msg = format!("{err:?}");
1598 assert!(
1599 matches!(err, AppError::Forbidden(_)) && msg.contains("expired"),
1600 "got {err:?}"
1601 );
1602 }
1603
1604 #[tokio::test]
1605 async fn check_acl_full_returns_role_and_contexts() {
1606 let (store, _dir) = temp_store();
1607 let acl = store.keyspace("acl").unwrap();
1608 store_acl_entry(
1609 &acl,
1610 &scoped_entry("did:key:zCtx", Role::Admin, &["ctx1", "ctx2"]),
1611 )
1612 .await
1613 .unwrap();
1614
1615 let (role, contexts) = check_acl_full(&acl, "did:key:zCtx").await.unwrap();
1616 assert_eq!(role, Role::Admin);
1617 assert_eq!(contexts, vec!["ctx1".to_string(), "ctx2".to_string()]);
1618 }
1619
1620 #[test]
1623 fn role_assignment_super_admin_can_assign_admin() {
1624 validate_role_assignment(&super_admin_claims(), &Role::Admin)
1625 .expect("super admin assigns admin");
1626 }
1627
1628 #[test]
1629 fn role_assignment_context_admin_can_assign_admin_role_itself() {
1630 validate_role_assignment(&context_admin_claims(&["ctx1"]), &Role::Admin)
1636 .expect("context admin CAN assign Admin role; scope is enforced separately");
1637 }
1638
1639 #[test]
1640 fn role_assignment_non_admin_cannot_assign_admin() {
1641 let initiator = AuthClaims {
1645 did: "did:key:zIni".into(),
1646 role: Role::Initiator,
1647 allowed_contexts: vec!["ctx1".into()],
1648 session_id: "test-session".into(),
1649 access_expires_at: 0,
1650 amr: Vec::new(),
1651 acr: String::new(),
1652 };
1653 let err = validate_role_assignment(&initiator, &Role::Admin)
1654 .expect_err("non-admin must not assign admin");
1655 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1656 }
1657
1658 #[test]
1659 fn role_assignment_readers_cannot_assign_any_role() {
1660 let reader = AuthClaims {
1661 did: "did:key:zReader".into(),
1662 role: Role::Reader,
1663 allowed_contexts: vec!["ctx1".into()],
1664 session_id: "test-session".into(),
1665 access_expires_at: 0,
1666 amr: Vec::new(),
1667 acr: String::new(),
1668 };
1669 for target in [
1670 Role::Admin,
1671 Role::Initiator,
1672 Role::Application,
1673 Role::Reader,
1674 Role::Monitor,
1675 ] {
1676 let err = validate_role_assignment(&reader, &target)
1677 .expect_err(&format!("reader must not assign {target}"));
1678 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1679 }
1680 }
1681
1682 #[test]
1683 fn role_assignment_initiator_can_assign_non_admin_roles() {
1684 let initiator = AuthClaims {
1685 did: "did:key:zIni".into(),
1686 role: Role::Initiator,
1687 allowed_contexts: vec!["ctx1".into()],
1688 session_id: "test-session".into(),
1689 access_expires_at: 0,
1690 amr: Vec::new(),
1691 acr: String::new(),
1692 };
1693 validate_role_assignment(&initiator, &Role::Reader).expect("initiator can assign reader");
1694 validate_role_assignment(&initiator, &Role::Application)
1695 .expect("initiator can assign application");
1696 }
1697
1698 #[test]
1701 fn acl_modification_super_admin_can_create_anything() {
1702 validate_acl_modification(&super_admin_claims(), &Role::Admin, &[])
1704 .expect("super admin unrestricted");
1705 validate_acl_modification(&super_admin_claims(), &Role::Reader, &["any-ctx".into()])
1706 .expect("super admin any-context");
1707 }
1708
1709 #[test]
1710 fn acl_modification_context_admin_cannot_create_unrestricted() {
1711 let err = validate_acl_modification(&context_admin_claims(&["ctx1"]), &Role::Admin, &[])
1714 .expect_err("context admin must not create an unrestricted entry");
1715 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1716 }
1717
1718 #[test]
1723 fn acl_modification_context_admin_can_create_acts_nowhere() {
1724 for role in [
1725 Role::Reader,
1726 Role::Application,
1727 Role::Initiator,
1728 Role::Monitor,
1729 ] {
1730 validate_acl_modification(&context_admin_claims(&["ctx1"]), &role, &[]).unwrap_or_else(
1731 |e| panic!("context admin must be able to create an acts-nowhere {role:?}: {e:?}"),
1732 );
1733 }
1734 }
1735
1736 #[test]
1737 fn acl_modification_context_admin_confined_to_own_contexts() {
1738 let caller = context_admin_claims(&["ctx1", "ctx2"]);
1739 validate_acl_modification(&caller, &Role::Reader, &["ctx1".into()])
1740 .expect("own context ok");
1741 validate_acl_modification(&caller, &Role::Reader, &["ctx1".into(), "ctx2".into()])
1742 .expect("all-own contexts ok");
1743
1744 let err = validate_acl_modification(&caller, &Role::Reader, &["ctx3".into()])
1745 .expect_err("foreign context must be rejected");
1746 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1747
1748 let err =
1749 validate_acl_modification(&caller, &Role::Reader, &["ctx1".into(), "ctx3".into()])
1750 .expect_err("mixed own+foreign must be rejected");
1751 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1752 }
1753
1754 #[test]
1761 fn can_act_in_includes_super_admins() {
1762 let super_admin = sample_entry("did:key:zSuper", Role::Admin);
1763 assert!(acl_entry_can_act_in(&super_admin, "anything"));
1764 }
1765
1766 #[test]
1769 fn can_act_in_excludes_acts_nowhere_entries() {
1770 for role in [Role::Reader, Role::Application, Role::Initiator] {
1771 let entry = sample_entry("did:key:zNowhere", role.clone());
1772 assert!(
1773 !acl_entry_can_act_in(&entry, "anything"),
1774 "{role:?} with no contexts acts nowhere"
1775 );
1776 }
1777 }
1778
1779 #[test]
1783 fn can_act_in_covers_the_subtree() {
1784 let entry = scoped_entry("did:key:zParent", Role::Reader, &["parent"]);
1785 assert!(acl_entry_can_act_in(&entry, "parent"), "self");
1786 assert!(acl_entry_can_act_in(&entry, "parent/child"), "subtree");
1787 assert!(!acl_entry_can_act_in(&entry, "other"), "unrelated");
1788 assert!(
1789 !acl_entry_can_act_in(&entry, "parentless"),
1790 "a string prefix is not an ancestor"
1791 );
1792 }
1793
1794 #[test]
1799 fn can_act_in_resolves_the_offline_online_disagreement() {
1800 let ctx = "openvtc";
1801 assert!(acl_entry_can_act_in(
1803 &sample_entry("did:key:zA", Role::Admin),
1804 ctx
1805 ));
1806 assert!(!acl_entry_can_act_in(
1808 &sample_entry("did:key:zB", Role::Reader),
1809 ctx
1810 ));
1811 }
1812
1813 #[test]
1820 fn a_subtree_sweep_finds_the_leaves_the_act_filter_omits() {
1821 let leaf = scoped_entry(
1822 "did:key:zGateway",
1823 Role::Application,
1824 &["acme/eng/attestation"],
1825 );
1826 let unit = "acme/eng";
1827
1828 assert!(
1829 !acl_entry_can_act_in(&leaf, unit),
1830 "correct for the act question, and the reason the sweep missed it"
1831 );
1832 assert!(acl_entry_acts_within(&leaf, unit), "the sweep must see it");
1833 assert!(acl_entry_matches_context(
1834 &leaf,
1835 unit,
1836 ContextDirection::Any
1837 ));
1838 }
1839
1840 #[test]
1843 fn a_subtree_sweep_stops_at_the_branch() {
1844 let sibling = scoped_entry("did:key:zOps", Role::Reader, &["acme/ops/keys"]);
1845 let lookalike = scoped_entry("did:key:zLook", Role::Reader, &["acme/engineering"]);
1846 assert!(!acl_entry_acts_within(&sibling, "acme/eng"));
1847 assert!(!acl_entry_acts_within(&lookalike, "acme/eng"));
1848 assert!(
1849 acl_entry_acts_within(&lookalike, "acme"),
1850 "still under acme"
1851 );
1852 }
1853
1854 #[test]
1859 fn a_subtree_sweep_does_not_offer_up_the_super_admin() {
1860 let super_admin = sample_entry("did:key:zSuper", Role::Admin);
1861 assert!(!acl_entry_acts_within(&super_admin, "acme/eng"));
1862 assert!(acl_entry_matches_context(
1863 &super_admin,
1864 "acme/eng",
1865 ContextDirection::ActingIn
1866 ));
1867 assert!(acl_entry_matches_context(
1868 &super_admin,
1869 "acme/eng",
1870 ContextDirection::Any
1871 ));
1872 }
1873
1874 #[test]
1876 fn acts_nowhere_is_in_no_direction() {
1877 let nowhere = sample_entry("did:key:zNowhere", Role::Reader);
1878 for d in ContextDirection::ALL {
1879 assert!(
1880 !acl_entry_matches_context(&nowhere, "acme", d),
1881 "acts-nowhere surfaced under {d}"
1882 );
1883 }
1884 }
1885
1886 #[test]
1888 fn the_default_direction_reproduces_the_old_filter() {
1889 let entries = [
1890 sample_entry("did:key:zSuper", Role::Admin),
1891 sample_entry("did:key:zNowhere", Role::Reader),
1892 scoped_entry("did:key:zParent", Role::Reader, &["acme"]),
1893 scoped_entry("did:key:zSelf", Role::Reader, &["acme/eng"]),
1894 scoped_entry("did:key:zLeaf", Role::Reader, &["acme/eng/team-a"]),
1895 ];
1896 for entry in &entries {
1897 assert_eq!(
1898 acl_entry_matches_context(entry, "acme/eng", ContextDirection::default()),
1899 acl_entry_can_act_in(entry, "acme/eng"),
1900 "default direction diverged for {}",
1901 entry.did
1902 );
1903 }
1904 }
1905
1906 #[test]
1913 fn auditable_surfaces_an_approver_scoped_to_the_callers_context() {
1914 let caller = context_admin_claims(&["ctx1"]);
1915 let approver = AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zSetup")
1916 .with_approve_scope(ApproveScope::Contexts(vec!["ctx1".into()]));
1917
1918 assert!(
1919 !is_acl_entry_visible(&caller, &approver),
1920 "acts nowhere, so it is not the caller's to manage"
1921 );
1922 assert!(
1923 is_acl_entry_auditable(&caller, &approver),
1924 "but it confers in ctx1, so ctx1's admin must be able to see it"
1925 );
1926 }
1927
1928 #[test]
1930 fn auditable_surfaces_an_approve_all_holder() {
1931 let caller = context_admin_claims(&["ctx1"]);
1932 let approver = AclEntry::new("did:key:zGlobal", Role::Reader, "did:key:zSetup")
1933 .with_approve_scope(ApproveScope::All);
1934 assert!(is_acl_entry_auditable(&caller, &approver));
1935 }
1936
1937 #[test]
1939 fn auditable_follows_context_ancestry() {
1940 let caller = context_admin_claims(&["parent"]);
1941 let approver = AclEntry::new("did:key:zChild", Role::Reader, "did:key:zSetup")
1942 .with_approve_scope(ApproveScope::Contexts(vec!["parent/child".into()]));
1943 assert!(
1944 is_acl_entry_auditable(&caller, &approver),
1945 "an admin of the parent context administers the subtree it confers in"
1946 );
1947 }
1948
1949 #[test]
1952 fn auditable_hides_an_approver_for_a_foreign_context() {
1953 let caller = context_admin_claims(&["ctx1"]);
1954 let approver = AclEntry::new("did:key:zOther", Role::Reader, "did:key:zSetup")
1955 .with_approve_scope(ApproveScope::Contexts(vec!["ctx2".into()]));
1956 assert!(!is_acl_entry_auditable(&caller, &approver));
1957 }
1958
1959 #[test]
1964 fn auditable_does_not_confer_manage_authority() {
1965 let caller = context_admin_claims(&["ctx1"]);
1966 let foreign_admin = scoped_entry("did:key:zForeignAdmin", Role::Admin, &["ctx2"])
1967 .with_approve_scope(ApproveScope::Contexts(vec!["ctx1".into()]));
1968
1969 assert!(
1970 is_acl_entry_auditable(&caller, &foreign_admin),
1971 "confers into ctx1, so ctx1's admin can see it"
1972 );
1973 assert!(
1974 !is_acl_entry_visible(&caller, &foreign_admin),
1975 "but it admins ctx2 — ctx1's admin must not be able to update or delete it"
1976 );
1977 }
1978
1979 #[test]
1982 fn auditable_matches_visible_when_nothing_is_conferred() {
1983 let caller = context_admin_claims(&["ctx1"]);
1984 for entry in [
1985 scoped_entry("did:key:zA", Role::Reader, &["ctx1"]),
1986 scoped_entry("did:key:zB", Role::Reader, &["ctx2"]),
1987 sample_entry("did:key:zC", Role::Admin),
1988 sample_entry("did:key:zD", Role::Reader),
1989 ] {
1990 assert_eq!(
1991 is_acl_entry_auditable(&caller, &entry),
1992 is_acl_entry_visible(&caller, &entry),
1993 "{} must be unaffected by the approve axis",
1994 entry.did
1995 );
1996 }
1997 }
1998
1999 #[test]
2001 fn auditable_is_total_for_a_super_admin() {
2002 let caller = super_admin_claims();
2003 let entry = scoped_entry("did:key:zAny", Role::Reader, &["whatever"]);
2004 assert!(is_acl_entry_auditable(&caller, &entry));
2005 }
2006
2007 #[test]
2010 fn visibility_super_admin_sees_everything() {
2011 let caller = super_admin_claims();
2012 assert!(is_acl_entry_visible(
2013 &caller,
2014 &sample_entry("did:key:zA", Role::Admin)
2015 ));
2016 assert!(is_acl_entry_visible(
2017 &caller,
2018 &scoped_entry("did:key:zB", Role::Admin, &["private"])
2019 ));
2020 }
2021
2022 #[test]
2023 fn visibility_context_admin_sees_overlapping_entries_only() {
2024 let caller = context_admin_claims(&["ctx1", "ctx2"]);
2025
2026 assert!(is_acl_entry_visible(
2028 &caller,
2029 &scoped_entry("did:key:zA", Role::Reader, &["ctx1"])
2030 ));
2031
2032 assert!(!is_acl_entry_visible(
2034 &caller,
2035 &scoped_entry("did:key:zB", Role::Reader, &["ctx3"])
2036 ));
2037
2038 assert!(!is_acl_entry_visible(
2041 &caller,
2042 &sample_entry("did:key:zSuper", Role::Admin)
2043 ));
2044
2045 assert!(is_acl_entry_visible(
2047 &caller,
2048 &scoped_entry("did:key:zC", Role::Reader, &["ctx2", "ctx99"])
2049 ));
2050 }
2051
2052 #[test]
2055 fn acl_entry_without_expires_at_deserializes() {
2056 let legacy = r#"{
2061 "did": "did:key:zLegacy",
2062 "role": "admin",
2063 "label": "old admin",
2064 "allowed_contexts": [],
2065 "created_at": 1700000000,
2066 "created_by": "did:key:zSetup"
2067 }"#;
2068 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
2069 assert_eq!(entry.did, "did:key:zLegacy");
2070 assert!(entry.expires_at.is_none(), "default to permanent");
2071 }
2072
2073 #[test]
2074 fn acl_entry_with_missing_allowed_contexts_defaults_to_empty() {
2075 let legacy = r#"{
2077 "did": "did:key:zLegacy",
2078 "role": "admin",
2079 "label": null,
2080 "created_at": 1700000000,
2081 "created_by": "did:key:zSetup"
2082 }"#;
2083 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
2084 assert!(entry.allowed_contexts.is_empty());
2085 }
2086
2087 #[test]
2088 fn acl_entry_without_approve_scope_defaults_to_none() {
2089 let legacy = r#"{
2092 "did": "did:key:zLegacy",
2093 "role": "reader",
2094 "label": null,
2095 "created_at": 1700000000,
2096 "created_by": "did:key:zSetup"
2097 }"#;
2098 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
2099 assert_eq!(entry.approve_scope, ApproveScope::None);
2100 assert!(entry.approve_scope.confers_nothing());
2101 }
2102
2103 #[test]
2104 fn approve_scope_round_trips_on_the_wire() {
2105 for scope in [
2106 ApproveScope::None,
2107 ApproveScope::All,
2108 ApproveScope::Contexts(vec!["ctx-a".into(), "ctx-b".into()]),
2109 ] {
2110 let e = AclEntry::new("did:key:zA", Role::Reader, "did:key:zC")
2111 .with_approve_scope(scope.clone());
2112 let json = serde_json::to_string(&e).unwrap();
2113 let back: AclEntry = serde_json::from_str(&json).unwrap();
2114 assert_eq!(back.approve_scope, scope);
2115 }
2116 }
2117
2118 #[test]
2119 fn validate_approve_scope_grant_authority() {
2120 validate_approve_scope_grant(&super_admin_claims(), &ApproveScope::All)
2122 .expect("super admin may grant approve-all");
2123 assert!(
2124 validate_approve_scope_grant(&context_admin_claims(&["ctx-a"]), &ApproveScope::All)
2125 .is_err(),
2126 "a context admin must not grant approve-all"
2127 );
2128
2129 let ctx_admin = context_admin_claims(&["ctx-a"]);
2131 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-a".into()]))
2132 .expect("own context ok");
2133 assert!(
2134 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-b".into()]))
2135 .is_err(),
2136 "foreign context must be rejected"
2137 );
2138
2139 validate_approve_scope_grant(&ctx_admin, &ApproveScope::None).expect("none ok");
2141 assert!(
2142 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec![])).is_err(),
2143 "empty scope must name a context or use all"
2144 );
2145 }
2146
2147 #[test]
2154 fn blank_context_ids_are_rejected_on_every_acl_write_path() {
2155 let blank = vec![String::new()];
2156
2157 assert!(
2158 validate_acl_modification(&super_admin_claims(), &Role::Reader, &blank).is_err(),
2159 "a super admin must not store a context named empty-string"
2160 );
2161 assert!(
2162 validate_acl_modification(&context_admin_claims(&["ctx-a"]), &Role::Reader, &blank)
2163 .is_err(),
2164 "nor may a context admin"
2165 );
2166 assert!(
2167 validate_approve_scope_grant(
2168 &super_admin_claims(),
2169 &ApproveScope::Contexts(blank.clone())
2170 )
2171 .is_err(),
2172 "an approve scope of [\"\"] names no context"
2173 );
2174
2175 for bad in ["/ctx", "ctx/", "a//b", "ev il"] {
2178 assert!(
2179 validate_acl_modification(&super_admin_claims(), &Role::Reader, &[bad.to_string()])
2180 .is_err(),
2181 "{bad} must be rejected"
2182 );
2183 }
2184
2185 validate_acl_modification(&super_admin_claims(), &Role::Admin, &[])
2188 .expect("admin + empty list is still how super admin is expressed");
2189 validate_acl_modification(
2190 &super_admin_claims(),
2191 &Role::Reader,
2192 &["acme/eng".to_string()],
2193 )
2194 .expect("a well-formed nested path is still accepted");
2195 }
2196}