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
263#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
277#[serde(rename_all = "snake_case", tag = "kind", content = "contexts")]
278pub enum ApproveScope {
279 #[default]
281 None,
282 All,
285 Contexts(Vec<String>),
287}
288
289impl ApproveScope {
290 pub fn covers(&self, context_id: &str) -> bool {
295 match self {
296 ApproveScope::None => false,
297 ApproveScope::All => true,
298 ApproveScope::Contexts(cs) => cs
299 .iter()
300 .any(|c| crate::context_path::is_ancestor_or_self(c, context_id)),
301 }
302 }
303
304 pub fn confers_nothing(&self) -> bool {
306 matches!(self, ApproveScope::None)
307 }
308}
309
310pub fn validate_approve_scope_grant(
317 caller: &AuthClaims,
318 scope: &ApproveScope,
319) -> Result<(), AppError> {
320 match scope {
321 ApproveScope::None => Ok(()),
322 ApproveScope::All => {
323 if caller.is_super_admin() {
324 Ok(())
325 } else {
326 Err(AppError::Forbidden(
327 "only super admin can grant approve-all authority".into(),
328 ))
329 }
330 }
331 ApproveScope::Contexts(cs) => {
332 if cs.is_empty() {
333 return Err(AppError::Forbidden(
334 "approve scope must name at least one context (or use 'all')".into(),
335 ));
336 }
337 for c in cs {
340 crate::context_path::validate_context_path(c)?;
341 }
342 for c in cs {
343 caller.require_context(c)?;
344 }
345 Ok(())
346 }
347 }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct AclEntry {
353 pub did: String,
354 pub role: Role,
355 pub label: Option<String>,
356 #[serde(default)]
357 pub allowed_contexts: Vec<String>,
358 pub created_at: u64,
359 pub created_by: String,
360 #[serde(default)]
365 pub expires_at: Option<u64>,
366 #[serde(default)]
370 pub kind: ConsumerKind,
371 #[serde(default)]
375 pub capabilities: Vec<Capability>,
376 #[serde(default)]
380 pub device: Option<DeviceBinding>,
381 #[serde(default)]
391 pub version: u32,
392 #[serde(default)]
402 pub step_up_approver: Option<String>,
403 #[serde(default)]
413 pub step_up_require: Option<StepUpMode>,
414 #[serde(default)]
421 pub approve_scope: ApproveScope,
422}
423
424impl AclEntry {
425 pub fn new(did: impl Into<String>, role: Role, created_by: impl Into<String>) -> Self {
434 Self {
435 did: did.into(),
436 role,
437 label: None,
438 allowed_contexts: Vec::new(),
439 created_at: crate::auth::session::now_epoch(),
440 created_by: created_by.into(),
441 expires_at: None,
442 kind: ConsumerKind::default(),
443 capabilities: Vec::new(),
444 device: None,
445 version: 0,
446 step_up_approver: None,
447 step_up_require: None,
448 approve_scope: ApproveScope::None,
449 }
450 }
451
452 pub fn with_created_at(mut self, created_at: u64) -> Self {
455 self.created_at = created_at;
456 self
457 }
458
459 pub fn with_label(mut self, label: Option<String>) -> Self {
461 self.label = label;
462 self
463 }
464
465 pub fn with_contexts(mut self, allowed_contexts: Vec<String>) -> Self {
467 self.allowed_contexts = allowed_contexts;
468 self
469 }
470
471 pub fn with_expires_at(mut self, expires_at: Option<u64>) -> Self {
473 self.expires_at = expires_at;
474 self
475 }
476
477 pub fn with_kind(mut self, kind: ConsumerKind) -> Self {
479 self.kind = kind;
480 self
481 }
482
483 pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
485 self.capabilities = capabilities;
486 self
487 }
488
489 pub fn with_device(mut self, device: Option<DeviceBinding>) -> Self {
491 self.device = device;
492 self
493 }
494
495 pub fn with_step_up_approver(mut self, approver: Option<String>) -> Self {
497 self.step_up_approver = approver;
498 self
499 }
500
501 pub fn with_step_up_require(mut self, require: Option<StepUpMode>) -> Self {
503 self.step_up_require = require;
504 self
505 }
506
507 pub fn with_approve_scope(mut self, approve_scope: ApproveScope) -> Self {
510 self.approve_scope = approve_scope;
511 self
512 }
513
514 pub fn is_admin(&self) -> bool {
516 matches!(self.role, Role::Admin)
517 }
518
519 pub fn is_super_admin(&self) -> bool {
523 self.is_admin() && self.allowed_contexts.is_empty()
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_contexts: &[String],
725) -> Result<(), AppError> {
726 for ctx in target_contexts {
734 crate::context_path::validate_context_path(ctx)?;
735 }
736 if caller.is_super_admin() {
737 return Ok(());
738 }
739 if target_contexts.is_empty() {
740 return Err(AppError::Forbidden(
741 "only super admin can create unrestricted accounts".into(),
742 ));
743 }
744 for ctx in target_contexts {
745 caller.require_context(ctx)?;
746 }
747 Ok(())
748}
749
750pub fn delegated_any_approver_covers(approver: &AclEntry, subject: &AclEntry) -> bool {
764 match &approver.approve_scope {
770 ApproveScope::All => return true,
771 ApproveScope::Contexts(_) => {
772 if !subject.allowed_contexts.is_empty()
773 && subject
774 .allowed_contexts
775 .iter()
776 .all(|c| approver.approve_scope.covers(c))
777 {
778 return true;
779 }
780 }
782 ApproveScope::None => {}
783 }
784
785 if !approver.is_admin() {
787 return false;
788 }
789 if approver.allowed_contexts.is_empty() {
790 return true; }
792 !subject.allowed_contexts.is_empty()
796 && subject
797 .allowed_contexts
798 .iter()
799 .all(|c| approver.allowed_contexts.contains(c))
800}
801
802pub fn is_acl_entry_visible(caller: &AuthClaims, entry: &AclEntry) -> bool {
807 if caller.is_super_admin() {
808 return true;
809 }
810 entry
811 .allowed_contexts
812 .iter()
813 .any(|ctx| caller.has_context_access(ctx))
814}
815
816#[cfg(test)]
817mod delegated_any_tests {
818 use super::*;
819
820 fn admin(contexts: &[&str]) -> AclEntry {
821 AclEntry::new("did:key:zApprover", Role::Admin, "did:key:zCreator")
822 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
823 }
824 fn subject(role: Role, contexts: &[&str]) -> AclEntry {
825 AclEntry::new("did:key:zSubject", role, "did:key:zCreator")
826 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
827 }
828
829 #[test]
830 fn super_admin_covers_any_subject() {
831 let sa = admin(&[]); assert!(delegated_any_approver_covers(
833 &sa,
834 &subject(Role::Admin, &["ctx-a"])
835 ));
836 assert!(delegated_any_approver_covers(
837 &sa,
838 &subject(Role::Reader, &[])
839 )); assert!(delegated_any_approver_covers(
841 &sa,
842 &subject(Role::Application, &["ctx-a", "ctx-b"])
843 ));
844 }
845
846 #[test]
847 fn context_admin_covers_only_within_its_contexts() {
848 let ca = admin(&["ctx-a", "ctx-b"]);
849 assert!(delegated_any_approver_covers(
851 &ca,
852 &subject(Role::Reader, &["ctx-a"])
853 ));
854 assert!(delegated_any_approver_covers(
855 &ca,
856 &subject(Role::Reader, &["ctx-a", "ctx-b"])
857 ));
858 assert!(!delegated_any_approver_covers(
860 &ca,
861 &subject(Role::Reader, &["ctx-c"])
862 ));
863 assert!(!delegated_any_approver_covers(
864 &ca,
865 &subject(Role::Reader, &["ctx-a", "ctx-c"])
866 ));
867 }
868
869 #[test]
870 fn context_admin_never_covers_a_global_subject() {
871 let ca = admin(&["ctx-a"]);
874 assert!(!delegated_any_approver_covers(
875 &ca,
876 &subject(Role::Admin, &[])
877 ));
878 }
879
880 #[test]
881 fn non_admins_never_qualify() {
882 for role in [
883 Role::Initiator,
884 Role::Application,
885 Role::Reader,
886 Role::Monitor,
887 ] {
888 let not_admin = AclEntry::new("did:key:zX", role, "did:key:zC");
889 assert!(!delegated_any_approver_covers(
890 ¬_admin,
891 &subject(Role::Reader, &["ctx-a"])
892 ));
893 }
894 }
895
896 fn pure_approver(scope: ApproveScope) -> AclEntry {
901 AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zCreator")
902 .with_approve_scope(scope)
903 }
904
905 #[test]
906 fn approve_scope_covers_semantics() {
907 assert!(!ApproveScope::None.covers("ctx-a"));
908 assert!(ApproveScope::All.covers("anything"));
909 let scoped = ApproveScope::Contexts(vec!["ctx-a".into()]);
910 assert!(scoped.covers("ctx-a"));
911 assert!(!scoped.covers("ctx-b"));
912 }
913
914 #[test]
915 fn approve_all_confers_for_any_subject_without_any_admin_authority() {
916 let approver = pure_approver(ApproveScope::All);
919 assert!(
920 !approver.is_admin(),
921 "the approver holds no admin authority"
922 );
923 assert!(delegated_any_approver_covers(
924 &approver,
925 &subject(Role::Reader, &["ctx-a"])
926 ));
927 assert!(delegated_any_approver_covers(
928 &approver,
929 &subject(Role::Admin, &[])
930 ));
931 }
932
933 #[test]
934 fn scoped_approve_authority_covers_only_within_scope() {
935 let approver = pure_approver(ApproveScope::Contexts(vec!["ctx-a".into()]));
936 assert!(delegated_any_approver_covers(
937 &approver,
938 &subject(Role::Reader, &["ctx-a"])
939 ));
940 assert!(!delegated_any_approver_covers(
941 &approver,
942 &subject(Role::Reader, &["ctx-b"])
943 ));
944 assert!(!delegated_any_approver_covers(
946 &approver,
947 &subject(Role::Admin, &[])
948 ));
949 }
950
951 #[test]
952 fn no_approve_scope_and_no_admin_confers_nothing() {
953 let reader = pure_approver(ApproveScope::None);
954 assert!(!delegated_any_approver_covers(
955 &reader,
956 &subject(Role::Reader, &["ctx-a"])
957 ));
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use crate::config::StoreConfig;
965 use crate::store::Store;
966
967 fn temp_store() -> (Store, tempfile::TempDir) {
970 let dir = tempfile::tempdir().expect("tempdir");
971 let config = StoreConfig {
972 data_dir: dir.path().to_path_buf(),
973 };
974 let store = Store::open(&config).expect("open store");
975 (store, dir)
976 }
977
978 fn sample_entry(did: &str, role: Role) -> AclEntry {
979 AclEntry::new(did, role, "did:key:zSetup").with_label(Some(format!("test-{did}")))
980 }
981
982 #[tokio::test]
983 async fn list_acl_entries_skips_corrupt_rows() {
984 let (store, _dir) = temp_store();
988 let ks = store.keyspace("acl").unwrap();
989
990 store_acl_entry(&ks, &sample_entry("did:key:zAlice", Role::Admin))
991 .await
992 .unwrap();
993 ks.insert_raw("acl:did:key:zCorrupt", b"{not valid json".to_vec())
995 .await
996 .unwrap();
997 store_acl_entry(&ks, &sample_entry("did:key:zBob", Role::Reader))
998 .await
999 .unwrap();
1000
1001 let entries = list_acl_entries(&ks).await.expect("listing must not abort");
1002 let dids: Vec<&str> = entries.iter().map(|e| e.did.as_str()).collect();
1003 assert!(dids.contains(&"did:key:zAlice"));
1004 assert!(dids.contains(&"did:key:zBob"));
1005 assert_eq!(entries.len(), 2, "the corrupt row is skipped, not surfaced");
1006 }
1007
1008 fn scoped_entry(did: &str, role: Role, contexts: &[&str]) -> AclEntry {
1009 AclEntry::new(did, role, "did:key:zSetup")
1010 .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
1011 }
1012
1013 fn super_admin_claims() -> AuthClaims {
1014 AuthClaims {
1015 did: "did:key:zSuperAdmin".into(),
1016 role: Role::Admin,
1017 allowed_contexts: vec![],
1018 session_id: "test-session".into(),
1019 access_expires_at: 0,
1020 amr: Vec::new(),
1021 acr: String::new(),
1022 }
1023 }
1024
1025 fn context_admin_claims(contexts: &[&str]) -> AuthClaims {
1026 AuthClaims {
1027 did: "did:key:zCtxAdmin".into(),
1028 role: Role::Admin,
1029 allowed_contexts: contexts.iter().map(|s| s.to_string()).collect(),
1030 session_id: "test-session".into(),
1031 access_expires_at: 0,
1032 amr: Vec::new(),
1033 acr: String::new(),
1034 }
1035 }
1036
1037 #[test]
1040 fn role_parse_accepts_canonical_lowercase() {
1041 assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
1042 assert_eq!(Role::parse("initiator").unwrap(), Role::Initiator);
1043 assert_eq!(Role::parse("application").unwrap(), Role::Application);
1044 assert_eq!(Role::parse("reader").unwrap(), Role::Reader);
1045 assert_eq!(Role::parse("monitor").unwrap(), Role::Monitor);
1046 }
1047
1048 #[test]
1049 fn role_parse_rejects_unknown() {
1050 let err = Role::parse("godmode").expect_err("unknown role must error");
1051 assert!(format!("{err:?}").contains("godmode"), "got {err:?}");
1052 }
1053
1054 fn sample_binding() -> DeviceBinding {
1057 DeviceBinding {
1058 device_id: "dev-1".into(),
1059 display_name: "Glenn's iPhone".into(),
1060 platform: Some("iOS 19".into()),
1061 registered_at: "2026-06-02T00:00:00Z".into(),
1062 last_seen_at: None,
1063 disabled_at: None,
1064 wiped_at: None,
1065 hpke_public_key: None,
1066 wake: None,
1067 }
1068 }
1069
1070 #[test]
1071 fn wake_channel_round_trips_camel_case() {
1072 let mut b = sample_binding();
1073 b.wake = Some(WakeChannel {
1074 gateway: "https://gw.example".into(),
1075 handle: "z6MkOpaque".into(),
1076 allowed_triggers: vec!["did:web:mediator".into(), "did:web:vta".into()],
1077 });
1078 let json = serde_json::to_string(&b).unwrap();
1079 assert!(json.contains("\"wake\""), "{json}");
1081 assert!(json.contains("\"allowedTriggers\""), "{json}");
1082 let back: DeviceBinding = serde_json::from_str(&json).unwrap();
1083 assert_eq!(b, back);
1084 }
1085
1086 #[test]
1087 fn legacy_row_without_wake_deserialises_to_none() {
1088 let legacy =
1090 r#"{"deviceId":"dev-1","displayName":"old","registeredAt":"2026-01-01T00:00:00Z"}"#;
1091 let b: DeviceBinding = serde_json::from_str(legacy).unwrap();
1092 assert!(b.wake.is_none());
1093 assert!(!b.push_capable());
1094 }
1095
1096 #[test]
1097 fn push_capable_requires_wake_and_active_device() {
1098 let mut b = sample_binding();
1099 assert!(!b.push_capable(), "no wake channel → not push-capable");
1100
1101 b.wake = Some(WakeChannel {
1102 gateway: "did:web:gw".into(),
1103 handle: "h".into(),
1104 allowed_triggers: vec!["did:web:vta".into()],
1105 });
1106 assert!(b.push_capable(), "wake set + active → push-capable");
1107
1108 b.disabled_at = Some("2026-06-02T01:00:00Z".into());
1109 assert!(!b.push_capable(), "disabled device is not push-capable");
1110
1111 b.disabled_at = None;
1112 b.wiped_at = Some("2026-06-02T02:00:00Z".into());
1113 assert!(!b.push_capable(), "wiped device is not push-capable");
1114 }
1115
1116 #[test]
1117 fn role_parse_rejects_case_variation() {
1118 assert!(Role::parse("Admin").is_err(), "case-sensitive parse");
1121 assert!(Role::parse("ADMIN").is_err());
1122 }
1123
1124 #[test]
1125 fn role_display_round_trips_with_parse() {
1126 for role in [
1127 Role::Admin,
1128 Role::Initiator,
1129 Role::Application,
1130 Role::Reader,
1131 Role::Monitor,
1132 ] {
1133 let s = format!("{role}");
1134 assert_eq!(Role::parse(&s).unwrap(), role, "display->parse cycle");
1135 }
1136 }
1137
1138 #[test]
1141 fn entry_without_expiry_never_expires() {
1142 let entry = sample_entry("did:key:zA", Role::Admin);
1143 assert!(entry.expires_at.is_none());
1144 assert!(
1145 !entry.is_expired(u64::MAX),
1146 "permanent entries never expire"
1147 );
1148 }
1149
1150 #[test]
1151 fn entry_with_future_expiry_is_not_expired() {
1152 let mut entry = sample_entry("did:key:zA", Role::Admin);
1153 entry.expires_at = Some(now_epoch() + 3600);
1154 assert!(!entry.is_expired(now_epoch()));
1155 }
1156
1157 #[test]
1158 fn entry_with_past_expiry_is_expired() {
1159 let mut entry = sample_entry("did:key:zA", Role::Admin);
1160 entry.expires_at = Some(now_epoch().saturating_sub(1));
1161 assert!(entry.is_expired(now_epoch()));
1162 }
1163
1164 #[test]
1165 fn entry_with_exact_expiry_boundary_is_expired() {
1166 let mut entry = sample_entry("did:key:zA", Role::Admin);
1170 let now = now_epoch();
1171 entry.expires_at = Some(now);
1172 assert!(entry.is_expired(now), "now == deadline counts as expired");
1173 }
1174
1175 #[tokio::test]
1178 async fn crud_round_trip() {
1179 let (store, _dir) = temp_store();
1180 let acl = store.keyspace("acl").unwrap();
1181
1182 let entry = sample_entry("did:key:zAbc", Role::Admin);
1183 store_acl_entry(&acl, &entry).await.unwrap();
1184
1185 let got = get_acl_entry(&acl, "did:key:zAbc")
1186 .await
1187 .unwrap()
1188 .expect("entry should exist");
1189 assert_eq!(got.did, entry.did);
1190 assert_eq!(got.role, Role::Admin);
1191
1192 delete_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1193 let gone = get_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1194 assert!(gone.is_none(), "deleted entry must be gone");
1195 }
1196
1197 #[tokio::test]
1198 async fn list_returns_every_entry() {
1199 let (store, _dir) = temp_store();
1200 let acl = store.keyspace("acl").unwrap();
1201
1202 for did in ["did:key:zA", "did:key:zB", "did:key:zC"] {
1203 store_acl_entry(&acl, &sample_entry(did, Role::Reader))
1204 .await
1205 .unwrap();
1206 }
1207
1208 let entries = list_acl_entries(&acl).await.unwrap();
1209 assert_eq!(entries.len(), 3);
1210 let dids: std::collections::HashSet<_> = entries.iter().map(|e| e.did.as_str()).collect();
1211 assert!(dids.contains("did:key:zA"));
1212 assert!(dids.contains("did:key:zB"));
1213 assert!(dids.contains("did:key:zC"));
1214 }
1215
1216 #[tokio::test]
1219 async fn check_acl_returns_role_for_present_did() {
1220 let (store, _dir) = temp_store();
1221 let acl = store.keyspace("acl").unwrap();
1222 store_acl_entry(&acl, &sample_entry("did:key:zA", Role::Initiator))
1223 .await
1224 .unwrap();
1225
1226 let role = check_acl(&acl, "did:key:zA").await.unwrap();
1227 assert_eq!(role, Role::Initiator);
1228 }
1229
1230 #[tokio::test]
1231 async fn check_acl_rejects_missing_did_as_forbidden() {
1232 let (store, _dir) = temp_store();
1233 let acl = store.keyspace("acl").unwrap();
1234
1235 let err = check_acl(&acl, "did:key:zUnknown")
1236 .await
1237 .expect_err("missing DID must be rejected");
1238 assert!(
1239 matches!(err, AppError::Forbidden(_)),
1240 "got {err:?}; expected Forbidden so the handler emits 403"
1241 );
1242 }
1243
1244 #[tokio::test]
1245 async fn check_acl_rejects_expired_entry() {
1246 let (store, _dir) = temp_store();
1247 let acl = store.keyspace("acl").unwrap();
1248
1249 let mut entry = sample_entry("did:key:zExpired", Role::Admin);
1250 entry.expires_at = Some(now_epoch().saturating_sub(10));
1251 store_acl_entry(&acl, &entry).await.unwrap();
1252
1253 let err = check_acl(&acl, "did:key:zExpired")
1254 .await
1255 .expect_err("expired entry must be rejected");
1256 let msg = format!("{err:?}");
1257 assert!(
1258 matches!(err, AppError::Forbidden(_)) && msg.contains("expired"),
1259 "got {err:?}"
1260 );
1261 }
1262
1263 #[tokio::test]
1264 async fn check_acl_full_returns_role_and_contexts() {
1265 let (store, _dir) = temp_store();
1266 let acl = store.keyspace("acl").unwrap();
1267 store_acl_entry(
1268 &acl,
1269 &scoped_entry("did:key:zCtx", Role::Admin, &["ctx1", "ctx2"]),
1270 )
1271 .await
1272 .unwrap();
1273
1274 let (role, contexts) = check_acl_full(&acl, "did:key:zCtx").await.unwrap();
1275 assert_eq!(role, Role::Admin);
1276 assert_eq!(contexts, vec!["ctx1".to_string(), "ctx2".to_string()]);
1277 }
1278
1279 #[test]
1282 fn role_assignment_super_admin_can_assign_admin() {
1283 validate_role_assignment(&super_admin_claims(), &Role::Admin)
1284 .expect("super admin assigns admin");
1285 }
1286
1287 #[test]
1288 fn role_assignment_context_admin_can_assign_admin_role_itself() {
1289 validate_role_assignment(&context_admin_claims(&["ctx1"]), &Role::Admin)
1295 .expect("context admin CAN assign Admin role; scope is enforced separately");
1296 }
1297
1298 #[test]
1299 fn role_assignment_non_admin_cannot_assign_admin() {
1300 let initiator = AuthClaims {
1304 did: "did:key:zIni".into(),
1305 role: Role::Initiator,
1306 allowed_contexts: vec!["ctx1".into()],
1307 session_id: "test-session".into(),
1308 access_expires_at: 0,
1309 amr: Vec::new(),
1310 acr: String::new(),
1311 };
1312 let err = validate_role_assignment(&initiator, &Role::Admin)
1313 .expect_err("non-admin must not assign admin");
1314 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1315 }
1316
1317 #[test]
1318 fn role_assignment_readers_cannot_assign_any_role() {
1319 let reader = AuthClaims {
1320 did: "did:key:zReader".into(),
1321 role: Role::Reader,
1322 allowed_contexts: vec!["ctx1".into()],
1323 session_id: "test-session".into(),
1324 access_expires_at: 0,
1325 amr: Vec::new(),
1326 acr: String::new(),
1327 };
1328 for target in [
1329 Role::Admin,
1330 Role::Initiator,
1331 Role::Application,
1332 Role::Reader,
1333 Role::Monitor,
1334 ] {
1335 let err = validate_role_assignment(&reader, &target)
1336 .expect_err(&format!("reader must not assign {target}"));
1337 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1338 }
1339 }
1340
1341 #[test]
1342 fn role_assignment_initiator_can_assign_non_admin_roles() {
1343 let initiator = AuthClaims {
1344 did: "did:key:zIni".into(),
1345 role: Role::Initiator,
1346 allowed_contexts: vec!["ctx1".into()],
1347 session_id: "test-session".into(),
1348 access_expires_at: 0,
1349 amr: Vec::new(),
1350 acr: String::new(),
1351 };
1352 validate_role_assignment(&initiator, &Role::Reader).expect("initiator can assign reader");
1353 validate_role_assignment(&initiator, &Role::Application)
1354 .expect("initiator can assign application");
1355 }
1356
1357 #[test]
1360 fn acl_modification_super_admin_can_create_unrestricted() {
1361 validate_acl_modification(&super_admin_claims(), &[]).expect("super admin unrestricted");
1362 validate_acl_modification(&super_admin_claims(), &["any-ctx".into()])
1363 .expect("super admin any-context");
1364 }
1365
1366 #[test]
1367 fn acl_modification_context_admin_cannot_create_unrestricted() {
1368 let err = validate_acl_modification(&context_admin_claims(&["ctx1"]), &[])
1371 .expect_err("context admin must not create unrestricted");
1372 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1373 }
1374
1375 #[test]
1376 fn acl_modification_context_admin_confined_to_own_contexts() {
1377 let caller = context_admin_claims(&["ctx1", "ctx2"]);
1378 validate_acl_modification(&caller, &["ctx1".into()]).expect("own context ok");
1379 validate_acl_modification(&caller, &["ctx1".into(), "ctx2".into()])
1380 .expect("all-own contexts ok");
1381
1382 let err = validate_acl_modification(&caller, &["ctx3".into()])
1383 .expect_err("foreign context must be rejected");
1384 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1385
1386 let err = validate_acl_modification(&caller, &["ctx1".into(), "ctx3".into()])
1387 .expect_err("mixed own+foreign must be rejected");
1388 assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1389 }
1390
1391 #[test]
1394 fn visibility_super_admin_sees_everything() {
1395 let caller = super_admin_claims();
1396 assert!(is_acl_entry_visible(
1397 &caller,
1398 &sample_entry("did:key:zA", Role::Admin)
1399 ));
1400 assert!(is_acl_entry_visible(
1401 &caller,
1402 &scoped_entry("did:key:zB", Role::Admin, &["private"])
1403 ));
1404 }
1405
1406 #[test]
1407 fn visibility_context_admin_sees_overlapping_entries_only() {
1408 let caller = context_admin_claims(&["ctx1", "ctx2"]);
1409
1410 assert!(is_acl_entry_visible(
1412 &caller,
1413 &scoped_entry("did:key:zA", Role::Reader, &["ctx1"])
1414 ));
1415
1416 assert!(!is_acl_entry_visible(
1418 &caller,
1419 &scoped_entry("did:key:zB", Role::Reader, &["ctx3"])
1420 ));
1421
1422 assert!(!is_acl_entry_visible(
1425 &caller,
1426 &sample_entry("did:key:zSuper", Role::Admin)
1427 ));
1428
1429 assert!(is_acl_entry_visible(
1431 &caller,
1432 &scoped_entry("did:key:zC", Role::Reader, &["ctx2", "ctx99"])
1433 ));
1434 }
1435
1436 #[test]
1439 fn acl_entry_without_expires_at_deserializes() {
1440 let legacy = r#"{
1445 "did": "did:key:zLegacy",
1446 "role": "admin",
1447 "label": "old admin",
1448 "allowed_contexts": [],
1449 "created_at": 1700000000,
1450 "created_by": "did:key:zSetup"
1451 }"#;
1452 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1453 assert_eq!(entry.did, "did:key:zLegacy");
1454 assert!(entry.expires_at.is_none(), "default to permanent");
1455 }
1456
1457 #[test]
1458 fn acl_entry_with_missing_allowed_contexts_defaults_to_empty() {
1459 let legacy = r#"{
1461 "did": "did:key:zLegacy",
1462 "role": "admin",
1463 "label": null,
1464 "created_at": 1700000000,
1465 "created_by": "did:key:zSetup"
1466 }"#;
1467 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1468 assert!(entry.allowed_contexts.is_empty());
1469 }
1470
1471 #[test]
1472 fn acl_entry_without_approve_scope_defaults_to_none() {
1473 let legacy = r#"{
1476 "did": "did:key:zLegacy",
1477 "role": "reader",
1478 "label": null,
1479 "created_at": 1700000000,
1480 "created_by": "did:key:zSetup"
1481 }"#;
1482 let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1483 assert_eq!(entry.approve_scope, ApproveScope::None);
1484 assert!(entry.approve_scope.confers_nothing());
1485 }
1486
1487 #[test]
1488 fn approve_scope_round_trips_on_the_wire() {
1489 for scope in [
1490 ApproveScope::None,
1491 ApproveScope::All,
1492 ApproveScope::Contexts(vec!["ctx-a".into(), "ctx-b".into()]),
1493 ] {
1494 let e = AclEntry::new("did:key:zA", Role::Reader, "did:key:zC")
1495 .with_approve_scope(scope.clone());
1496 let json = serde_json::to_string(&e).unwrap();
1497 let back: AclEntry = serde_json::from_str(&json).unwrap();
1498 assert_eq!(back.approve_scope, scope);
1499 }
1500 }
1501
1502 #[test]
1503 fn validate_approve_scope_grant_authority() {
1504 validate_approve_scope_grant(&super_admin_claims(), &ApproveScope::All)
1506 .expect("super admin may grant approve-all");
1507 assert!(
1508 validate_approve_scope_grant(&context_admin_claims(&["ctx-a"]), &ApproveScope::All)
1509 .is_err(),
1510 "a context admin must not grant approve-all"
1511 );
1512
1513 let ctx_admin = context_admin_claims(&["ctx-a"]);
1515 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-a".into()]))
1516 .expect("own context ok");
1517 assert!(
1518 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-b".into()]))
1519 .is_err(),
1520 "foreign context must be rejected"
1521 );
1522
1523 validate_approve_scope_grant(&ctx_admin, &ApproveScope::None).expect("none ok");
1525 assert!(
1526 validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec![])).is_err(),
1527 "empty scope must name a context or use all"
1528 );
1529 }
1530
1531 #[test]
1538 fn blank_context_ids_are_rejected_on_every_acl_write_path() {
1539 let blank = vec![String::new()];
1540
1541 assert!(
1542 validate_acl_modification(&super_admin_claims(), &blank).is_err(),
1543 "a super admin must not store a context named empty-string"
1544 );
1545 assert!(
1546 validate_acl_modification(&context_admin_claims(&["ctx-a"]), &blank).is_err(),
1547 "nor may a context admin"
1548 );
1549 assert!(
1550 validate_approve_scope_grant(
1551 &super_admin_claims(),
1552 &ApproveScope::Contexts(blank.clone())
1553 )
1554 .is_err(),
1555 "an approve scope of [\"\"] names no context"
1556 );
1557
1558 for bad in ["/ctx", "ctx/", "a//b", "ev il"] {
1561 assert!(
1562 validate_acl_modification(&super_admin_claims(), &[bad.to_string()]).is_err(),
1563 "{bad} must be rejected"
1564 );
1565 }
1566
1567 validate_acl_modification(&super_admin_claims(), &[])
1570 .expect("an empty list is still how super admin is expressed");
1571 validate_acl_modification(&super_admin_claims(), &["acme/eng".to_string()])
1572 .expect("a well-formed nested path is still accepted");
1573 }
1574}