1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::auth::extractor::AuthClaims;
6use crate::auth::step_up::StepUpMode;
7use crate::error::AppError;
8use crate::store::KeyspaceHandle;
9
10#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
19#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20#[serde(rename_all = "lowercase")]
21pub enum Role {
22 Admin,
23 Initiator,
24 Application,
25 Reader,
26 #[default]
32 Monitor,
33}
34
35impl fmt::Display for Role {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 Role::Admin => write!(f, "admin"),
39 Role::Initiator => write!(f, "initiator"),
40 Role::Application => write!(f, "application"),
41 Role::Reader => write!(f, "reader"),
42 Role::Monitor => write!(f, "monitor"),
43 }
44 }
45}
46
47impl Role {
48 pub fn parse(s: &str) -> Result<Self, AppError> {
50 match s {
51 "admin" => Ok(Role::Admin),
52 "initiator" => Ok(Role::Initiator),
53 "application" => Ok(Role::Application),
54 "reader" => Ok(Role::Reader),
55 "monitor" => Ok(Role::Monitor),
56 _ => Err(AppError::Internal(format!("unknown role: {s}"))),
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74#[serde(tag = "kind", rename_all = "kebab-case")]
75pub enum ConsumerKind {
76 #[serde(rename_all = "kebab-case")]
77 Companion { form_factor: CompanionFormFactor },
78 #[serde(rename_all = "camelCase")]
79 Service {
80 #[serde(rename = "serviceKind")]
81 service_kind: ServiceKind,
82 },
83}
84
85impl Default for ConsumerKind {
86 fn default() -> Self {
87 ConsumerKind::Service {
88 service_kind: ServiceKind::Daemon,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "kebab-case")]
95pub enum CompanionFormFactor {
96 Browser,
97 Mobile,
98 Desktop,
99}
100
101#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "kebab-case")]
103pub enum ServiceKind {
104 Mediator,
105 AiAgent,
106 Daemon,
107}
108
109#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
121#[serde(rename_all = "kebab-case")]
122pub enum Capability {
123 VaultRead,
124 VaultWrite,
125 ProxyLogin,
126 FillRelease,
127 PolicyAdmin,
128 DeviceAdmin,
129 Sign,
130 KeyMint,
131 SignTrustTask,
137 CredentialWrite,
145}
146
147pub fn role_has_capability(role: &Role, cap: Capability) -> bool {
152 derived_capabilities_for_role(role).contains(&cap)
153}
154
155pub fn derived_capabilities_for_role(role: &Role) -> Vec<Capability> {
160 match role {
161 Role::Admin => vec![
162 Capability::VaultRead,
163 Capability::VaultWrite,
164 Capability::CredentialWrite,
165 Capability::ProxyLogin,
166 Capability::FillRelease,
167 Capability::PolicyAdmin,
168 Capability::DeviceAdmin,
169 Capability::Sign,
170 Capability::SignTrustTask,
171 Capability::KeyMint,
172 ],
173 Role::Initiator => vec![
174 Capability::VaultRead,
175 Capability::VaultWrite,
176 Capability::CredentialWrite,
177 Capability::ProxyLogin,
178 Capability::FillRelease,
179 Capability::DeviceAdmin,
180 Capability::Sign,
181 Capability::SignTrustTask,
182 Capability::KeyMint,
183 ],
184 Role::Application => vec![
185 Capability::VaultRead,
186 Capability::ProxyLogin,
187 Capability::FillRelease,
188 Capability::Sign,
189 Capability::SignTrustTask,
190 ],
191 Role::Reader => vec![Capability::VaultRead],
192 Role::Monitor => vec![],
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
205#[serde(rename_all = "camelCase")]
206pub struct WakeChannel {
207 pub gateway: String,
210 pub handle: String,
213 #[serde(default)]
217 pub allowed_triggers: Vec<String>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
227#[serde(rename_all = "camelCase")]
228pub struct DeviceBinding {
229 pub device_id: String,
230 pub display_name: String,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub platform: Option<String>,
233 pub registered_at: String,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub last_seen_at: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub disabled_at: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub wiped_at: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub hpke_public_key: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub wake: Option<WakeChannel>,
252}
253
254impl DeviceBinding {
255 pub fn push_capable(&self) -> bool {
259 self.wake.is_some() && self.disabled_at.is_none() && self.wiped_at.is_none()
260 }
261}
262
263pub use vta_sdk::acl::{ActScope, ApproveScope};
272
273pub fn act_scope_for(role: &Role, allowed_contexts: &[String]) -> ActScope {
292 match (role, allowed_contexts) {
293 (_, cs) if !cs.is_empty() => ActScope::Contexts(cs.to_vec()),
294 (Role::Admin, _) => ActScope::All,
295 _ => ActScope::None,
296 }
297}
298
299pub fn validate_approve_scope_grant(
306 caller: &AuthClaims,
307 scope: &ApproveScope,
308) -> Result<(), AppError> {
309 match scope {
310 ApproveScope::None => Ok(()),
311 ApproveScope::All => {
312 if caller.is_super_admin() {
313 Ok(())
314 } else {
315 Err(AppError::Forbidden(
316 "only super admin can grant approve-all authority".into(),
317 ))
318 }
319 }
320 ApproveScope::Contexts(cs) => {
321 if cs.is_empty() {
322 return Err(AppError::Forbidden(
323 "approve scope must name at least one context (or use 'all')".into(),
324 ));
325 }
326 for c in cs {
329 crate::context_path::validate_context_path(c)?;
330 }
331 for c in cs {
332 caller.require_context(c)?;
333 }
334 Ok(())
335 }
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct AclEntry {
342 pub did: String,
343 pub role: Role,
344 pub label: Option<String>,
345 #[serde(default)]
346 pub allowed_contexts: Vec<String>,
347 pub created_at: u64,
348 pub created_by: String,
349 #[serde(default)]
354 pub expires_at: Option<u64>,
355 #[serde(default)]
359 pub kind: ConsumerKind,
360 #[serde(default)]
364 pub capabilities: Vec<Capability>,
365 #[serde(default)]
369 pub device: Option<DeviceBinding>,
370 #[serde(default)]
380 pub version: u32,
381 #[serde(default)]
391 pub step_up_approver: Option<String>,
392 #[serde(default)]
402 pub step_up_require: Option<StepUpMode>,
403 #[serde(default)]
410 pub approve_scope: ApproveScope,
411}
412
413impl AclEntry {
414 pub fn new(did: impl Into<String>, role: Role, created_by: impl Into<String>) -> Self {
423 Self {
424 did: did.into(),
425 role,
426 label: None,
427 allowed_contexts: Vec::new(),
428 created_at: crate::auth::session::now_epoch(),
429 created_by: created_by.into(),
430 expires_at: None,
431 kind: ConsumerKind::default(),
432 capabilities: Vec::new(),
433 device: None,
434 version: 0,
435 step_up_approver: None,
436 step_up_require: None,
437 approve_scope: ApproveScope::None,
438 }
439 }
440
441 pub fn with_created_at(mut self, created_at: u64) -> Self {
444 self.created_at = created_at;
445 self
446 }
447
448 pub fn with_label(mut self, label: Option<String>) -> Self {
450 self.label = label;
451 self
452 }
453
454 pub fn with_contexts(mut self, allowed_contexts: Vec<String>) -> Self {
456 self.allowed_contexts = allowed_contexts;
457 self
458 }
459
460 pub fn with_expires_at(mut self, expires_at: Option<u64>) -> Self {
462 self.expires_at = expires_at;
463 self
464 }
465
466 pub fn with_kind(mut self, kind: ConsumerKind) -> Self {
468 self.kind = kind;
469 self
470 }
471
472 pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
474 self.capabilities = capabilities;
475 self
476 }
477
478 pub fn with_device(mut self, device: Option<DeviceBinding>) -> Self {
480 self.device = device;
481 self
482 }
483
484 pub fn with_step_up_approver(mut self, approver: Option<String>) -> Self {
486 self.step_up_approver = approver;
487 self
488 }
489
490 pub fn with_step_up_require(mut self, require: Option<StepUpMode>) -> Self {
492 self.step_up_require = require;
493 self
494 }
495
496 pub fn with_approve_scope(mut self, approve_scope: ApproveScope) -> Self {
499 self.approve_scope = approve_scope;
500 self
501 }
502
503 pub fn is_admin(&self) -> bool {
505 matches!(self.role, Role::Admin)
506 }
507
508 pub fn act_scope(&self) -> ActScope {
512 act_scope_for(&self.role, &self.allowed_contexts)
513 }
514
515 pub fn can_act_in(&self, context_id: &str) -> bool {
517 self.act_scope().covers(context_id)
518 }
519
520 pub fn is_super_admin(&self) -> bool {
523 self.is_admin() && self.act_scope().is_unrestricted()
524 }
525
526 pub fn with_version(mut self, version: u32) -> Self {
528 self.version = version;
529 self
530 }
531
532 pub fn is_expired(&self, now_unix: u64) -> bool {
535 match self.expires_at {
536 Some(deadline) => now_unix >= deadline,
537 None => false,
538 }
539 }
540
541 pub fn etag(&self) -> String {
553 use std::collections::hash_map::DefaultHasher;
554 use std::hash::{Hash, Hasher};
555 let mut h = DefaultHasher::new();
556 self.did.hash(&mut h);
557 format!("W/\"{:016x}:{}\"", h.finish(), self.version)
558 }
559}
560
561fn acl_key(did: &str) -> String {
562 format!("acl:{did}")
563}
564
565pub async fn get_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<Option<AclEntry>, AppError> {
567 acl.get(acl_key(did)).await
568}
569
570pub async fn store_acl_entry(acl: &KeyspaceHandle, entry: &AclEntry) -> Result<(), AppError> {
578 acl.insert(acl_key(&entry.did), entry).await?;
579 crate::integrity::reseal_if_active().await
582}
583
584pub async fn update_acl_entry_versioned(
600 acl: &KeyspaceHandle,
601 mut new_entry: AclEntry,
602 expected_version: u32,
603) -> Result<u32, AppError> {
604 let key = acl_key(&new_entry.did);
605 let current: Option<AclEntry> = acl.get(key.clone()).await?;
606 let stored_version = current.as_ref().map(|e| e.version).unwrap_or(0);
607 if stored_version != expected_version {
608 return Err(AppError::Conflict(format!(
609 "ACL entry for {} has moved ahead (expected v{}, found v{}); re-read and retry",
610 new_entry.did, expected_version, stored_version,
611 )));
612 }
613 new_entry.version = expected_version + 1;
614 acl.insert(key, &new_entry).await?;
615 crate::integrity::reseal_if_active().await?; Ok(new_entry.version)
617}
618
619pub async fn delete_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
621 acl.remove(acl_key(did)).await?;
622 crate::integrity::reseal_if_active().await
625}
626
627pub async fn list_acl_entries(acl: &KeyspaceHandle) -> Result<Vec<AclEntry>, AppError> {
636 let raw = acl.prefix_iter_raw("acl:").await?;
637 let mut entries = Vec::with_capacity(raw.len());
638 let mut skipped = 0usize;
639 for (key, value) in raw {
640 match serde_json::from_slice::<AclEntry>(&value) {
641 Ok(entry) => entries.push(entry),
642 Err(e) => {
643 skipped += 1;
644 tracing::warn!(
645 key = %String::from_utf8_lossy(&key),
646 error = %e,
647 "skipping undeserializable ACL row in list_acl_entries"
648 );
649 }
650 }
651 }
652 if skipped > 0 {
653 tracing::warn!(skipped, "list_acl_entries skipped corrupt rows");
654 }
655 Ok(entries)
656}
657
658fn now_epoch() -> u64 {
659 std::time::SystemTime::now()
660 .duration_since(std::time::UNIX_EPOCH)
661 .map(|d| d.as_secs())
662 .unwrap_or(0)
663}
664
665pub async fn check_acl(acl: &KeyspaceHandle, did: &str) -> Result<Role, AppError> {
669 match get_acl_entry(acl, did).await? {
670 Some(entry) if entry.is_expired(now_epoch()) => {
671 Err(AppError::Forbidden(format!("ACL entry expired: {did}")))
672 }
673 Some(entry) => Ok(entry.role),
674 None => Err(AppError::Forbidden(format!("DID not in ACL: {did}"))),
675 }
676}
677
678pub async fn check_acl_full(
682 acl: &KeyspaceHandle,
683 did: &str,
684) -> Result<(Role, Vec<String>), AppError> {
685 match get_acl_entry(acl, did).await? {
686 Some(entry) if entry.is_expired(now_epoch()) => {
687 Err(AppError::Forbidden(format!("ACL entry expired: {did}")))
688 }
689 Some(entry) => Ok((entry.role, entry.allowed_contexts)),
690 None => Err(AppError::Forbidden(format!("DID not in ACL: {did}"))),
691 }
692}
693
694pub fn validate_role_assignment(caller: &AuthClaims, target_role: &Role) -> Result<(), AppError> {
699 if matches!(
700 caller.role,
701 Role::Monitor | Role::Reader | Role::Application
702 ) {
703 return Err(AppError::Forbidden(
704 "insufficient role to assign roles".into(),
705 ));
706 }
707 if *target_role == Role::Admin && caller.role != Role::Admin {
708 return Err(AppError::Forbidden(
709 "only admins can assign the admin role".into(),
710 ));
711 }
712 Ok(())
713}
714
715pub fn validate_acl_modification(
723 caller: &AuthClaims,
724 target_role: &Role,
725 target_contexts: &[String],
726) -> Result<(), AppError> {
727 for ctx in target_contexts {
735 crate::context_path::validate_context_path(ctx)?;
736 }
737 if caller.is_super_admin() {
738 return Ok(());
739 }
740 match act_scope_for(target_role, target_contexts) {
748 ActScope::All => Err(AppError::Forbidden(
751 "only super admin can create an unrestricted (super-admin) account".into(),
752 )),
753 ActScope::None => Ok(()),
759 ActScope::Contexts(cs) => {
761 for ctx in &cs {
762 caller.require_context(ctx)?;
763 }
764 Ok(())
765 }
766 }
767}
768
769pub fn delegated_any_approver_covers(approver: &AclEntry, subject: &AclEntry) -> bool {
783 match &approver.approve_scope {
789 ApproveScope::All => return true,
790 ApproveScope::Contexts(_) => {
791 if let ActScope::Contexts(subject_ctxs) = subject.act_scope()
795 && subject_ctxs
796 .iter()
797 .all(|c| approver.approve_scope.covers(c))
798 {
799 return true;
800 }
801 }
803 ApproveScope::None => {}
804 }
805
806 if !approver.is_admin() {
808 return false;
809 }
810 match approver.act_scope() {
811 ActScope::All => true,
813 approver_scope @ ActScope::Contexts(_) => match subject.act_scope() {
828 ActScope::Contexts(subject_ctxs) => {
829 subject_ctxs.iter().all(|c| approver_scope.covers(c))
830 }
831 _ => false,
832 },
833 ActScope::None => false,
835 }
836}
837
838pub fn acl_entry_can_act_in(entry: &AclEntry, context_id: &str) -> bool {
855 entry.act_scope().covers(context_id)
856}
857
858pub fn is_acl_entry_visible(caller: &AuthClaims, entry: &AclEntry) -> bool {
871 if caller.is_super_admin() {
872 return true;
873 }
874 entry
875 .act_scope()
876 .named_contexts()
877 .iter()
878 .any(|ctx| caller.has_context_access(ctx))
879}
880
881pub fn is_acl_entry_auditable(caller: &AuthClaims, entry: &AclEntry) -> bool {
900 if is_acl_entry_visible(caller, entry) {
901 return true;
902 }
903 match &entry.approve_scope {
904 ApproveScope::All => true,
906 ApproveScope::Contexts(cs) => cs.iter().any(|ctx| caller.has_context_access(ctx)),
907 ApproveScope::None => false,
908 }
909}
910
911#[cfg(test)]
912mod delegated_any_tests {
913 use super::*;
914
915 fn admin(contexts: &[&str]) -> AclEntry {
916 AclEntry::new("did:key:zApprover", Role::Admin, "did:key:zCreator")
917 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
918 }
919 fn subject(role: Role, contexts: &[&str]) -> AclEntry {
920 AclEntry::new("did:key:zSubject", role, "did:key:zCreator")
921 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
922 }
923
924 #[test]
931 fn context_admin_covers_a_subject_in_its_subtree() {
932 let approver = admin(&["acme"]);
933 assert!(
934 delegated_any_approver_covers(&approver, &subject(Role::Reader, &["acme/eng"])),
935 "an admin of `acme` administers `acme/eng`, so it may ratify for it"
936 );
937 assert!(
938 delegated_any_approver_covers(
939 &approver,
940 &subject(Role::Reader, &["acme/eng", "acme/sales"])
941 ),
942 "…and for a subject scoped to several of its descendants"
943 );
944 }
945
946 #[test]
949 fn context_admin_does_not_cover_a_prefix_sibling() {
950 let approver = admin(&["acme"]);
951 assert!(
952 !delegated_any_approver_covers(&approver, &subject(Role::Reader, &["acme-evil"])),
953 "`acme` must not cover `acme-evil` — segments, not string prefixes"
954 );
955 }
956
957 #[test]
960 fn context_admin_refuses_a_subject_partly_outside_its_subtree() {
961 let approver = admin(&["acme"]);
962 assert!(
963 !delegated_any_approver_covers(
964 &approver,
965 &subject(Role::Reader, &["acme/eng", "other"])
966 ),
967 "every subject context must fall within the approver's"
968 );
969 }
970
971 #[test]
974 fn flat_contexts_are_unchanged_by_ancestry() {
975 let approver = admin(&["ctx-a"]);
976 assert!(delegated_any_approver_covers(
977 &approver,
978 &subject(Role::Reader, &["ctx-a"])
979 ));
980 assert!(!delegated_any_approver_covers(
981 &approver,
982 &subject(Role::Reader, &["ctx-b"])
983 ));
984 }
985
986 #[test]
987 fn super_admin_covers_any_subject() {
988 let sa = admin(&[]); assert!(delegated_any_approver_covers(
990 &sa,
991 &subject(Role::Admin, &["ctx-a"])
992 ));
993 assert!(delegated_any_approver_covers(
994 &sa,
995 &subject(Role::Reader, &[])
996 )); assert!(delegated_any_approver_covers(
998 &sa,
999 &subject(Role::Application, &["ctx-a", "ctx-b"])
1000 ));
1001 }
1002
1003 #[test]
1004 fn context_admin_covers_only_within_its_contexts() {
1005 let ca = admin(&["ctx-a", "ctx-b"]);
1006 assert!(delegated_any_approver_covers(
1008 &ca,
1009 &subject(Role::Reader, &["ctx-a"])
1010 ));
1011 assert!(delegated_any_approver_covers(
1012 &ca,
1013 &subject(Role::Reader, &["ctx-a", "ctx-b"])
1014 ));
1015 assert!(!delegated_any_approver_covers(
1017 &ca,
1018 &subject(Role::Reader, &["ctx-c"])
1019 ));
1020 assert!(!delegated_any_approver_covers(
1021 &ca,
1022 &subject(Role::Reader, &["ctx-a", "ctx-c"])
1023 ));
1024 }
1025
1026 #[test]
1027 fn context_admin_never_covers_a_global_subject() {
1028 let ca = admin(&["ctx-a"]);
1031 assert!(!delegated_any_approver_covers(
1032 &ca,
1033 &subject(Role::Admin, &[])
1034 ));
1035 }
1036
1037 #[test]
1038 fn non_admins_never_qualify() {
1039 for role in [
1040 Role::Initiator,
1041 Role::Application,
1042 Role::Reader,
1043 Role::Monitor,
1044 ] {
1045 let not_admin = AclEntry::new("did:key:zX", role, "did:key:zC");
1046 assert!(!delegated_any_approver_covers(
1047 ¬_admin,
1048 &subject(Role::Reader, &["ctx-a"])
1049 ));
1050 }
1051 }
1052
1053 fn pure_approver(scope: ApproveScope) -> AclEntry {
1058 AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zCreator")
1059 .with_approve_scope(scope)
1060 }
1061
1062 #[test]
1063 fn approve_scope_covers_semantics() {
1064 assert!(!ApproveScope::None.covers("ctx-a"));
1065 assert!(ApproveScope::All.covers("anything"));
1066 let scoped = ApproveScope::Contexts(vec!["ctx-a".into()]);
1067 assert!(scoped.covers("ctx-a"));
1068 assert!(!scoped.covers("ctx-b"));
1069 }
1070
1071 #[test]
1072 fn approve_all_confers_for_any_subject_without_any_admin_authority() {
1073 let approver = pure_approver(ApproveScope::All);
1076 assert!(
1077 !approver.is_admin(),
1078 "the approver holds no admin authority"
1079 );
1080 assert!(delegated_any_approver_covers(
1081 &approver,
1082 &subject(Role::Reader, &["ctx-a"])
1083 ));
1084 assert!(delegated_any_approver_covers(
1085 &approver,
1086 &subject(Role::Admin, &[])
1087 ));
1088 }
1089
1090 #[test]
1091 fn scoped_approve_authority_covers_only_within_scope() {
1092 let approver = pure_approver(ApproveScope::Contexts(vec!["ctx-a".into()]));
1093 assert!(delegated_any_approver_covers(
1094 &approver,
1095 &subject(Role::Reader, &["ctx-a"])
1096 ));
1097 assert!(!delegated_any_approver_covers(
1098 &approver,
1099 &subject(Role::Reader, &["ctx-b"])
1100 ));
1101 assert!(!delegated_any_approver_covers(
1103 &approver,
1104 &subject(Role::Admin, &[])
1105 ));
1106 }
1107
1108 #[test]
1109 fn no_approve_scope_and_no_admin_confers_nothing() {
1110 let reader = pure_approver(ApproveScope::None);
1111 assert!(!delegated_any_approver_covers(
1112 &reader,
1113 &subject(Role::Reader, &["ctx-a"])
1114 ));
1115 }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121 use crate::config::StoreConfig;
1122 use crate::store::Store;
1123
1124 fn temp_store() -> (Store, tempfile::TempDir) {
1127 let dir = tempfile::tempdir().expect("tempdir");
1128 let config = StoreConfig {
1129 data_dir: dir.path().to_path_buf(),
1130 };
1131 let store = Store::open(&config).expect("open store");
1132 (store, dir)
1133 }
1134
1135 fn sample_entry(did: &str, role: Role) -> AclEntry {
1136 AclEntry::new(did, role, "did:key:zSetup").with_label(Some(format!("test-{did}")))
1137 }
1138
1139 #[tokio::test]
1140 async fn list_acl_entries_skips_corrupt_rows() {
1141 let (store, _dir) = temp_store();
1145 let ks = store.keyspace("acl").unwrap();
1146
1147 store_acl_entry(&ks, &sample_entry("did:key:zAlice", Role::Admin))
1148 .await
1149 .unwrap();
1150 ks.insert_raw("acl:did:key:zCorrupt", b"{not valid json".to_vec())
1152 .await
1153 .unwrap();
1154 store_acl_entry(&ks, &sample_entry("did:key:zBob", Role::Reader))
1155 .await
1156 .unwrap();
1157
1158 let entries = list_acl_entries(&ks).await.expect("listing must not abort");
1159 let dids: Vec<&str> = entries.iter().map(|e| e.did.as_str()).collect();
1160 assert!(dids.contains(&"did:key:zAlice"));
1161 assert!(dids.contains(&"did:key:zBob"));
1162 assert_eq!(entries.len(), 2, "the corrupt row is skipped, not surfaced");
1163 }
1164
1165 fn scoped_entry(did: &str, role: Role, contexts: &[&str]) -> AclEntry {
1166 AclEntry::new(did, role, "did:key:zSetup")
1167 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
1168 }
1169
1170 fn super_admin_claims() -> AuthClaims {
1171 AuthClaims {
1172 did: "did:key:zSuperAdmin".into(),
1173 role: Role::Admin,
1174 allowed_contexts: vec![],
1175 session_id: "test-session".into(),
1176 access_expires_at: 0,
1177 amr: Vec::new(),
1178 acr: String::new(),
1179 }
1180 }
1181
1182 fn context_admin_claims(contexts: &[&str]) -> AuthClaims {
1183 AuthClaims {
1184 did: "did:key:zCtxAdmin".into(),
1185 role: Role::Admin,
1186 allowed_contexts: contexts.iter().map(|s| s.to_string()).collect(),
1187 session_id: "test-session".into(),
1188 access_expires_at: 0,
1189 amr: Vec::new(),
1190 acr: String::new(),
1191 }
1192 }
1193
1194 #[test]
1197 fn role_parse_accepts_canonical_lowercase() {
1198 assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
1199 assert_eq!(Role::parse("initiator").unwrap(), Role::Initiator);
1200 assert_eq!(Role::parse("application").unwrap(), Role::Application);
1201 assert_eq!(Role::parse("reader").unwrap(), Role::Reader);
1202 assert_eq!(Role::parse("monitor").unwrap(), Role::Monitor);
1203 }
1204
1205 #[test]
1206 fn role_parse_rejects_unknown() {
1207 let err = Role::parse("godmode").expect_err("unknown role must error");
1208 assert!(format!("{err:?}").contains("godmode"), "got {err:?}");
1209 }
1210
1211 fn sample_binding() -> DeviceBinding {
1214 DeviceBinding {
1215 device_id: "dev-1".into(),
1216 display_name: "Glenn's iPhone".into(),
1217 platform: Some("iOS 19".into()),
1218 registered_at: "2026-06-02T00:00:00Z".into(),
1219 last_seen_at: None,
1220 disabled_at: None,
1221 wiped_at: None,
1222 hpke_public_key: None,
1223 wake: None,
1224 }
1225 }
1226
1227 #[test]
1228 fn wake_channel_round_trips_camel_case() {
1229 let mut b = sample_binding();
1230 b.wake = Some(WakeChannel {
1231 gateway: "https://gw.example".into(),
1232 handle: "z6MkOpaque".into(),
1233 allowed_triggers: vec!["did:web:mediator".into(), "did:web:vta".into()],
1234 });
1235 let json = serde_json::to_string(&b).unwrap();
1236 assert!(json.contains("\"wake\""), "{json}");
1238 assert!(json.contains("\"allowedTriggers\""), "{json}");
1239 let back: DeviceBinding = serde_json::from_str(&json).unwrap();
1240 assert_eq!(b, back);
1241 }
1242
1243 #[test]
1244 fn legacy_row_without_wake_deserialises_to_none() {
1245 let legacy =
1247 r#"{"deviceId":"dev-1","displayName":"old","registeredAt":"2026-01-01T00:00:00Z"}"#;
1248 let b: DeviceBinding = serde_json::from_str(legacy).unwrap();
1249 assert!(b.wake.is_none());
1250 assert!(!b.push_capable());
1251 }
1252
1253 #[test]
1254 fn push_capable_requires_wake_and_active_device() {
1255 let mut b = sample_binding();
1256 assert!(!b.push_capable(), "no wake channel → not push-capable");
1257
1258 b.wake = Some(WakeChannel {
1259 gateway: "did:web:gw".into(),
1260 handle: "h".into(),
1261 allowed_triggers: vec!["did:web:vta".into()],
1262 });
1263 assert!(b.push_capable(), "wake set + active → push-capable");
1264
1265 b.disabled_at = Some("2026-06-02T01:00:00Z".into());
1266 assert!(!b.push_capable(), "disabled device is not push-capable");
1267
1268 b.disabled_at = None;
1269 b.wiped_at = Some("2026-06-02T02:00:00Z".into());
1270 assert!(!b.push_capable(), "wiped device is not push-capable");
1271 }
1272
1273 #[test]
1274 fn role_parse_rejects_case_variation() {
1275 assert!(Role::parse("Admin").is_err(), "case-sensitive parse");
1278 assert!(Role::parse("ADMIN").is_err());
1279 }
1280
1281 #[test]
1282 fn role_display_round_trips_with_parse() {
1283 for role in [
1284 Role::Admin,
1285 Role::Initiator,
1286 Role::Application,
1287 Role::Reader,
1288 Role::Monitor,
1289 ] {
1290 let s = format!("{role}");
1291 assert_eq!(Role::parse(&s).unwrap(), role, "display->parse cycle");
1292 }
1293 }
1294
1295 #[test]
1298 fn entry_without_expiry_never_expires() {
1299 let entry = sample_entry("did:key:zA", Role::Admin);
1300 assert!(entry.expires_at.is_none());
1301 assert!(
1302 !entry.is_expired(u64::MAX),
1303 "permanent entries never expire"
1304 );
1305 }
1306
1307 #[test]
1308 fn entry_with_future_expiry_is_not_expired() {
1309 let mut entry = sample_entry("did:key:zA", Role::Admin);
1310 entry.expires_at = Some(now_epoch() + 3600);
1311 assert!(!entry.is_expired(now_epoch()));
1312 }
1313
1314 #[test]
1315 fn entry_with_past_expiry_is_expired() {
1316 let mut entry = sample_entry("did:key:zA", Role::Admin);
1317 entry.expires_at = Some(now_epoch().saturating_sub(1));
1318 assert!(entry.is_expired(now_epoch()));
1319 }
1320
1321 #[test]
1322 fn entry_with_exact_expiry_boundary_is_expired() {
1323 let mut entry = sample_entry("did:key:zA", Role::Admin);
1327 let now = now_epoch();
1328 entry.expires_at = Some(now);
1329 assert!(entry.is_expired(now), "now == deadline counts as expired");
1330 }
1331
1332 #[tokio::test]
1335 async fn crud_round_trip() {
1336 let (store, _dir) = temp_store();
1337 let acl = store.keyspace("acl").unwrap();
1338
1339 let entry = sample_entry("did:key:zAbc", Role::Admin);
1340 store_acl_entry(&acl, &entry).await.unwrap();
1341
1342 let got = get_acl_entry(&acl, "did:key:zAbc")
1343 .await
1344 .unwrap()
1345 .expect("entry should exist");
1346 assert_eq!(got.did, entry.did);
1347 assert_eq!(got.role, Role::Admin);
1348
1349 delete_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1350 let gone = get_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1351 assert!(gone.is_none(), "deleted entry must be gone");
1352 }
1353
1354 #[tokio::test]
1355 async fn list_returns_every_entry() {
1356 let (store, _dir) = temp_store();
1357 let acl = store.keyspace("acl").unwrap();
1358
1359 for did in ["did:key:zA", "did:key:zB", "did:key:zC"] {
1360 store_acl_entry(&acl, &sample_entry(did, Role::Reader))
1361 .await
1362 .unwrap();
1363 }
1364
1365 let entries = list_acl_entries(&acl).await.unwrap();
1366 assert_eq!(entries.len(), 3);
1367 let dids: std::collections::HashSet<_> = entries.iter().map(|e| e.did.as_str()).collect();
1368 assert!(dids.contains("did:key:zA"));
1369 assert!(dids.contains("did:key:zB"));
1370 assert!(dids.contains("did:key:zC"));
1371 }
1372
1373 #[tokio::test]
1376 async fn check_acl_returns_role_for_present_did() {
1377 let (store, _dir) = temp_store();
1378 let acl = store.keyspace("acl").unwrap();
1379 store_acl_entry(&acl, &sample_entry("did:key:zA", Role::Initiator))
1380 .await
1381 .unwrap();
1382
1383 let role = check_acl(&acl, "did:key:zA").await.unwrap();
1384 assert_eq!(role, Role::Initiator);
1385 }
1386
1387 #[tokio::test]
1388 async fn check_acl_rejects_missing_did_as_forbidden() {
1389 let (store, _dir) = temp_store();
1390 let acl = store.keyspace("acl").unwrap();
1391
1392 let err = check_acl(&acl, "did:key:zUnknown")
1393 .await
1394 .expect_err("missing DID must be rejected");
1395 assert!(
1396 matches!(err, AppError::Forbidden(_)),
1397 "got {err:?}; expected Forbidden so the handler emits 403"
1398 );
1399 }
1400
1401 #[tokio::test]
1402 async fn check_acl_rejects_expired_entry() {
1403 let (store, _dir) = temp_store();
1404 let acl = store.keyspace("acl").unwrap();
1405
1406 let mut entry = sample_entry("did:key:zExpired", Role::Admin);
1407 entry.expires_at = Some(now_epoch().saturating_sub(10));
1408 store_acl_entry(&acl, &entry).await.unwrap();
1409
1410 let err = check_acl(&acl, "did:key:zExpired")
1411 .await
1412 .expect_err("expired entry must be rejected");
1413 let msg = format!("{err:?}");
1414 assert!(
1415 matches!(err, AppError::Forbidden(_)) && msg.contains("expired"),
1416 "got {err:?}"
1417 );
1418 }
1419
1420 #[tokio::test]
1421 async fn check_acl_full_returns_role_and_contexts() {
1422 let (store, _dir) = temp_store();
1423 let acl = store.keyspace("acl").unwrap();
1424 store_acl_entry(
1425 &acl,
1426 &scoped_entry("did:key:zCtx", Role::Admin, &["ctx1", "ctx2"]),
1427 )
1428 .await
1429 .unwrap();
1430
1431 let (role, contexts) = check_acl_full(&acl, "did:key:zCtx").await.unwrap();
1432 assert_eq!(role, Role::Admin);
1433 assert_eq!(contexts, vec!["ctx1".to_string(), "ctx2".to_string()]);
1434 }
1435
1436 #[test]
1439 fn role_assignment_super_admin_can_assign_admin() {
1440 validate_role_assignment(&super_admin_claims(), &Role::Admin)
1441 .expect("super admin assigns admin");
1442 }
1443
1444 #[test]
1445 fn role_assignment_context_admin_can_assign_admin_role_itself() {
1446 validate_role_assignment(&context_admin_claims(&["ctx1"]), &Role::Admin)
1452 .expect("context admin CAN assign Admin role; scope is enforced separately");
1453 }
1454
1455 #[test]
1456 fn role_assignment_non_admin_cannot_assign_admin() {
1457 let initiator = AuthClaims {
1461 did: "did:key:zIni".into(),
1462 role: Role::Initiator,
1463 allowed_contexts: vec!["ctx1".into()],
1464 session_id: "test-session".into(),
1465 access_expires_at: 0,
1466 amr: Vec::new(),
1467 acr: String::new(),
1468 };
1469 let err = validate_role_assignment(&initiator, &Role::Admin)
1470 .expect_err("non-admin must not assign admin");
1471 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1472 }
1473
1474 #[test]
1475 fn role_assignment_readers_cannot_assign_any_role() {
1476 let reader = AuthClaims {
1477 did: "did:key:zReader".into(),
1478 role: Role::Reader,
1479 allowed_contexts: vec!["ctx1".into()],
1480 session_id: "test-session".into(),
1481 access_expires_at: 0,
1482 amr: Vec::new(),
1483 acr: String::new(),
1484 };
1485 for target in [
1486 Role::Admin,
1487 Role::Initiator,
1488 Role::Application,
1489 Role::Reader,
1490 Role::Monitor,
1491 ] {
1492 let err = validate_role_assignment(&reader, &target)
1493 .expect_err(&format!("reader must not assign {target}"));
1494 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1495 }
1496 }
1497
1498 #[test]
1499 fn role_assignment_initiator_can_assign_non_admin_roles() {
1500 let initiator = AuthClaims {
1501 did: "did:key:zIni".into(),
1502 role: Role::Initiator,
1503 allowed_contexts: vec!["ctx1".into()],
1504 session_id: "test-session".into(),
1505 access_expires_at: 0,
1506 amr: Vec::new(),
1507 acr: String::new(),
1508 };
1509 validate_role_assignment(&initiator, &Role::Reader).expect("initiator can assign reader");
1510 validate_role_assignment(&initiator, &Role::Application)
1511 .expect("initiator can assign application");
1512 }
1513
1514 #[test]
1517 fn acl_modification_super_admin_can_create_anything() {
1518 validate_acl_modification(&super_admin_claims(), &Role::Admin, &[])
1520 .expect("super admin unrestricted");
1521 validate_acl_modification(&super_admin_claims(), &Role::Reader, &["any-ctx".into()])
1522 .expect("super admin any-context");
1523 }
1524
1525 #[test]
1526 fn acl_modification_context_admin_cannot_create_unrestricted() {
1527 let err = validate_acl_modification(&context_admin_claims(&["ctx1"]), &Role::Admin, &[])
1530 .expect_err("context admin must not create an unrestricted entry");
1531 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1532 }
1533
1534 #[test]
1539 fn acl_modification_context_admin_can_create_acts_nowhere() {
1540 for role in [
1541 Role::Reader,
1542 Role::Application,
1543 Role::Initiator,
1544 Role::Monitor,
1545 ] {
1546 validate_acl_modification(&context_admin_claims(&["ctx1"]), &role, &[]).unwrap_or_else(
1547 |e| panic!("context admin must be able to create an acts-nowhere {role:?}: {e:?}"),
1548 );
1549 }
1550 }
1551
1552 #[test]
1553 fn acl_modification_context_admin_confined_to_own_contexts() {
1554 let caller = context_admin_claims(&["ctx1", "ctx2"]);
1555 validate_acl_modification(&caller, &Role::Reader, &["ctx1".into()])
1556 .expect("own context ok");
1557 validate_acl_modification(&caller, &Role::Reader, &["ctx1".into(), "ctx2".into()])
1558 .expect("all-own contexts ok");
1559
1560 let err = validate_acl_modification(&caller, &Role::Reader, &["ctx3".into()])
1561 .expect_err("foreign context must be rejected");
1562 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1563
1564 let err =
1565 validate_acl_modification(&caller, &Role::Reader, &["ctx1".into(), "ctx3".into()])
1566 .expect_err("mixed own+foreign must be rejected");
1567 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1568 }
1569
1570 #[test]
1577 fn can_act_in_includes_super_admins() {
1578 let super_admin = sample_entry("did:key:zSuper", Role::Admin);
1579 assert!(acl_entry_can_act_in(&super_admin, "anything"));
1580 }
1581
1582 #[test]
1585 fn can_act_in_excludes_acts_nowhere_entries() {
1586 for role in [Role::Reader, Role::Application, Role::Initiator] {
1587 let entry = sample_entry("did:key:zNowhere", role.clone());
1588 assert!(
1589 !acl_entry_can_act_in(&entry, "anything"),
1590 "{role:?} with no contexts acts nowhere"
1591 );
1592 }
1593 }
1594
1595 #[test]
1599 fn can_act_in_covers_the_subtree() {
1600 let entry = scoped_entry("did:key:zParent", Role::Reader, &["parent"]);
1601 assert!(acl_entry_can_act_in(&entry, "parent"), "self");
1602 assert!(acl_entry_can_act_in(&entry, "parent/child"), "subtree");
1603 assert!(!acl_entry_can_act_in(&entry, "other"), "unrelated");
1604 assert!(
1605 !acl_entry_can_act_in(&entry, "parentless"),
1606 "a string prefix is not an ancestor"
1607 );
1608 }
1609
1610 #[test]
1615 fn can_act_in_resolves_the_offline_online_disagreement() {
1616 let ctx = "openvtc";
1617 assert!(acl_entry_can_act_in(
1619 &sample_entry("did:key:zA", Role::Admin),
1620 ctx
1621 ));
1622 assert!(!acl_entry_can_act_in(
1624 &sample_entry("did:key:zB", Role::Reader),
1625 ctx
1626 ));
1627 }
1628
1629 #[test]
1636 fn auditable_surfaces_an_approver_scoped_to_the_callers_context() {
1637 let caller = context_admin_claims(&["ctx1"]);
1638 let approver = AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zSetup")
1639 .with_approve_scope(ApproveScope::Contexts(vec!["ctx1".into()]));
1640
1641 assert!(
1642 !is_acl_entry_visible(&caller, &approver),
1643 "acts nowhere, so it is not the caller's to manage"
1644 );
1645 assert!(
1646 is_acl_entry_auditable(&caller, &approver),
1647 "but it confers in ctx1, so ctx1's admin must be able to see it"
1648 );
1649 }
1650
1651 #[test]
1653 fn auditable_surfaces_an_approve_all_holder() {
1654 let caller = context_admin_claims(&["ctx1"]);
1655 let approver = AclEntry::new("did:key:zGlobal", Role::Reader, "did:key:zSetup")
1656 .with_approve_scope(ApproveScope::All);
1657 assert!(is_acl_entry_auditable(&caller, &approver));
1658 }
1659
1660 #[test]
1662 fn auditable_follows_context_ancestry() {
1663 let caller = context_admin_claims(&["parent"]);
1664 let approver = AclEntry::new("did:key:zChild", Role::Reader, "did:key:zSetup")
1665 .with_approve_scope(ApproveScope::Contexts(vec!["parent/child".into()]));
1666 assert!(
1667 is_acl_entry_auditable(&caller, &approver),
1668 "an admin of the parent context administers the subtree it confers in"
1669 );
1670 }
1671
1672 #[test]
1675 fn auditable_hides_an_approver_for_a_foreign_context() {
1676 let caller = context_admin_claims(&["ctx1"]);
1677 let approver = AclEntry::new("did:key:zOther", Role::Reader, "did:key:zSetup")
1678 .with_approve_scope(ApproveScope::Contexts(vec!["ctx2".into()]));
1679 assert!(!is_acl_entry_auditable(&caller, &approver));
1680 }
1681
1682 #[test]
1687 fn auditable_does_not_confer_manage_authority() {
1688 let caller = context_admin_claims(&["ctx1"]);
1689 let foreign_admin = scoped_entry("did:key:zForeignAdmin", Role::Admin, &["ctx2"])
1690 .with_approve_scope(ApproveScope::Contexts(vec!["ctx1".into()]));
1691
1692 assert!(
1693 is_acl_entry_auditable(&caller, &foreign_admin),
1694 "confers into ctx1, so ctx1's admin can see it"
1695 );
1696 assert!(
1697 !is_acl_entry_visible(&caller, &foreign_admin),
1698 "but it admins ctx2 — ctx1's admin must not be able to update or delete it"
1699 );
1700 }
1701
1702 #[test]
1705 fn auditable_matches_visible_when_nothing_is_conferred() {
1706 let caller = context_admin_claims(&["ctx1"]);
1707 for entry in [
1708 scoped_entry("did:key:zA", Role::Reader, &["ctx1"]),
1709 scoped_entry("did:key:zB", Role::Reader, &["ctx2"]),
1710 sample_entry("did:key:zC", Role::Admin),
1711 sample_entry("did:key:zD", Role::Reader),
1712 ] {
1713 assert_eq!(
1714 is_acl_entry_auditable(&caller, &entry),
1715 is_acl_entry_visible(&caller, &entry),
1716 "{} must be unaffected by the approve axis",
1717 entry.did
1718 );
1719 }
1720 }
1721
1722 #[test]
1724 fn auditable_is_total_for_a_super_admin() {
1725 let caller = super_admin_claims();
1726 let entry = scoped_entry("did:key:zAny", Role::Reader, &["whatever"]);
1727 assert!(is_acl_entry_auditable(&caller, &entry));
1728 }
1729
1730 #[test]
1733 fn visibility_super_admin_sees_everything() {
1734 let caller = super_admin_claims();
1735 assert!(is_acl_entry_visible(
1736 &caller,
1737 &sample_entry("did:key:zA", Role::Admin)
1738 ));
1739 assert!(is_acl_entry_visible(
1740 &caller,
1741 &scoped_entry("did:key:zB", Role::Admin, &["private"])
1742 ));
1743 }
1744
1745 #[test]
1746 fn visibility_context_admin_sees_overlapping_entries_only() {
1747 let caller = context_admin_claims(&["ctx1", "ctx2"]);
1748
1749 assert!(is_acl_entry_visible(
1751 &caller,
1752 &scoped_entry("did:key:zA", Role::Reader, &["ctx1"])
1753 ));
1754
1755 assert!(!is_acl_entry_visible(
1757 &caller,
1758 &scoped_entry("did:key:zB", Role::Reader, &["ctx3"])
1759 ));
1760
1761 assert!(!is_acl_entry_visible(
1764 &caller,
1765 &sample_entry("did:key:zSuper", Role::Admin)
1766 ));
1767
1768 assert!(is_acl_entry_visible(
1770 &caller,
1771 &scoped_entry("did:key:zC", Role::Reader, &["ctx2", "ctx99"])
1772 ));
1773 }
1774
1775 #[test]
1778 fn acl_entry_without_expires_at_deserializes() {
1779 let legacy = r#"{
1784 "did": "did:key:zLegacy",
1785 "role": "admin",
1786 "label": "old admin",
1787 "allowed_contexts": [],
1788 "created_at": 1700000000,
1789 "created_by": "did:key:zSetup"
1790 }"#;
1791 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1792 assert_eq!(entry.did, "did:key:zLegacy");
1793 assert!(entry.expires_at.is_none(), "default to permanent");
1794 }
1795
1796 #[test]
1797 fn acl_entry_with_missing_allowed_contexts_defaults_to_empty() {
1798 let legacy = r#"{
1800 "did": "did:key:zLegacy",
1801 "role": "admin",
1802 "label": null,
1803 "created_at": 1700000000,
1804 "created_by": "did:key:zSetup"
1805 }"#;
1806 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1807 assert!(entry.allowed_contexts.is_empty());
1808 }
1809
1810 #[test]
1811 fn acl_entry_without_approve_scope_defaults_to_none() {
1812 let legacy = r#"{
1815 "did": "did:key:zLegacy",
1816 "role": "reader",
1817 "label": null,
1818 "created_at": 1700000000,
1819 "created_by": "did:key:zSetup"
1820 }"#;
1821 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1822 assert_eq!(entry.approve_scope, ApproveScope::None);
1823 assert!(entry.approve_scope.confers_nothing());
1824 }
1825
1826 #[test]
1827 fn approve_scope_round_trips_on_the_wire() {
1828 for scope in [
1829 ApproveScope::None,
1830 ApproveScope::All,
1831 ApproveScope::Contexts(vec!["ctx-a".into(), "ctx-b".into()]),
1832 ] {
1833 let e = AclEntry::new("did:key:zA", Role::Reader, "did:key:zC")
1834 .with_approve_scope(scope.clone());
1835 let json = serde_json::to_string(&e).unwrap();
1836 let back: AclEntry = serde_json::from_str(&json).unwrap();
1837 assert_eq!(back.approve_scope, scope);
1838 }
1839 }
1840
1841 #[test]
1842 fn validate_approve_scope_grant_authority() {
1843 validate_approve_scope_grant(&super_admin_claims(), &ApproveScope::All)
1845 .expect("super admin may grant approve-all");
1846 assert!(
1847 validate_approve_scope_grant(&context_admin_claims(&["ctx-a"]), &ApproveScope::All)
1848 .is_err(),
1849 "a context admin must not grant approve-all"
1850 );
1851
1852 let ctx_admin = context_admin_claims(&["ctx-a"]);
1854 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-a".into()]))
1855 .expect("own context ok");
1856 assert!(
1857 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-b".into()]))
1858 .is_err(),
1859 "foreign context must be rejected"
1860 );
1861
1862 validate_approve_scope_grant(&ctx_admin, &ApproveScope::None).expect("none ok");
1864 assert!(
1865 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec![])).is_err(),
1866 "empty scope must name a context or use all"
1867 );
1868 }
1869
1870 #[test]
1877 fn blank_context_ids_are_rejected_on_every_acl_write_path() {
1878 let blank = vec![String::new()];
1879
1880 assert!(
1881 validate_acl_modification(&super_admin_claims(), &Role::Reader, &blank).is_err(),
1882 "a super admin must not store a context named empty-string"
1883 );
1884 assert!(
1885 validate_acl_modification(&context_admin_claims(&["ctx-a"]), &Role::Reader, &blank)
1886 .is_err(),
1887 "nor may a context admin"
1888 );
1889 assert!(
1890 validate_approve_scope_grant(
1891 &super_admin_claims(),
1892 &ApproveScope::Contexts(blank.clone())
1893 )
1894 .is_err(),
1895 "an approve scope of [\"\"] names no context"
1896 );
1897
1898 for bad in ["/ctx", "ctx/", "a//b", "ev il"] {
1901 assert!(
1902 validate_acl_modification(&super_admin_claims(), &Role::Reader, &[bad.to_string()])
1903 .is_err(),
1904 "{bad} must be rejected"
1905 );
1906 }
1907
1908 validate_acl_modification(&super_admin_claims(), &Role::Admin, &[])
1911 .expect("admin + empty list is still how super admin is expressed");
1912 validate_acl_modification(
1913 &super_admin_claims(),
1914 &Role::Reader,
1915 &["acme/eng".to_string()],
1916 )
1917 .expect("a well-formed nested path is still accepted");
1918 }
1919}