1use crate::codes::*;
2use crate::error::InvalidError;
3use crate::limits::{MAX_AUTHZ_MUTATION_ID_BYTES, MAX_ROLE_NAME_BYTES};
4use serde::{Deserialize, Serialize};
5
6#[derive(
8 Clone,
9 Copy,
10 Debug,
11 Default,
12 PartialEq,
13 Eq,
14 Serialize,
15 Deserialize,
16 strum::Display,
17 strum::EnumString,
18 strum::VariantArray,
19)]
20#[strum(serialize_all = "snake_case")]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum Effect {
24 #[default]
25 Allow,
26 Deny,
27}
28
29#[derive(
32 Clone,
33 Copy,
34 Debug,
35 PartialEq,
36 Eq,
37 Hash,
38 Serialize,
39 Deserialize,
40 strum::Display,
41 strum::EnumString,
42 strum::VariantArray,
43)]
44#[strum(serialize_all = "snake_case")]
45#[serde(rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum Feature {
48 Kv,
49 Memory,
50 Projection,
51 Fork,
52 Graph,
53 Query,
54 Agent,
55 Workflow,
56 Destination,
59 Checkpoint,
62 Authz,
65 KvLease,
72 KvFence,
76 Filter,
80 Session,
82 #[serde(other)]
90 Unrecognized,
91}
92
93pub const STREAM_RESOURCE_PREFIX: &str = "stream:";
96
97pub fn stream_resource(stream: &str) -> String {
99 format!("{STREAM_RESOURCE_PREFIX}{stream}")
100}
101
102pub fn scoped_resource(stream: &str, local: &str) -> String {
107 if local.starts_with(STREAM_RESOURCE_PREFIX) {
108 local.to_owned()
109 } else {
110 format!("{STREAM_RESOURCE_PREFIX}{stream}/{local}")
111 }
112}
113
114pub fn split_scoped_resource(name: &str) -> Option<(&str, &str)> {
118 let (stream, local) = name.strip_prefix(STREAM_RESOURCE_PREFIX)?.split_once('/')?;
119 (!stream.is_empty() && !local.is_empty()).then_some((stream, local))
120}
121
122#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum Tenancy {
127 #[default]
129 Deployment,
130 Stream,
133}
134
135impl Tenancy {
136 pub fn is_deployment(&self) -> bool {
137 *self == Self::Deployment
138 }
139}
140
141pub fn grant_spans_streams(grant: &Grant) -> bool {
145 if grant.effect == Effect::Deny {
146 return false;
147 }
148 match grant.resource.kind {
149 ResourceKind::Literal => false,
150 ResourceKind::Prefix => prefix_stream(&grant.resource.value).is_none(),
151 _ => true,
153 }
154}
155
156pub fn grants_stream(grants: &[Grant]) -> Option<String> {
160 let mut streams = grants
161 .iter()
162 .filter(|grant| grant.effect == Effect::Allow)
163 .map(|grant| match grant.resource.kind {
164 ResourceKind::Literal => split_scoped_resource(&grant.resource.value)
165 .map(|(stream, _)| stream)
166 .or_else(|| grant.resource.value.strip_prefix(STREAM_RESOURCE_PREFIX))
167 .filter(|stream| !stream.is_empty() && !stream.contains('/')),
168 ResourceKind::Prefix => prefix_stream(&grant.resource.value),
169 _ => None,
170 });
171 let first = streams.next()??;
172 streams
173 .all(|stream| stream == Some(first))
174 .then(|| first.to_owned())
175}
176
177fn prefix_stream(prefix: &str) -> Option<&str> {
179 if let Some((stream, _local)) = split_scoped_resource(prefix) {
180 return Some(stream);
181 }
182 prefix
183 .strip_prefix(STREAM_RESOURCE_PREFIX)?
184 .strip_suffix('/')
185 .filter(|stream| !stream.is_empty() && !stream.contains('/'))
186}
187
188#[derive(
190 Clone,
191 Copy,
192 Debug,
193 PartialEq,
194 Eq,
195 Hash,
196 Serialize,
197 Deserialize,
198 strum::Display,
199 strum::EnumString,
200 strum::VariantArray,
201)]
202#[strum(serialize_all = "snake_case")]
203#[serde(rename_all = "snake_case")]
204#[non_exhaustive]
205pub enum Action {
206 Read,
207 Write,
208 Delete,
209 Admin,
210 #[serde(other)]
215 Unrecognized,
216}
217
218#[derive(
220 Clone,
221 Copy,
222 Debug,
223 Default,
224 PartialEq,
225 Eq,
226 Serialize,
227 Deserialize,
228 strum::Display,
229 strum::EnumString,
230 strum::VariantArray,
231)]
232#[strum(serialize_all = "snake_case")]
233#[serde(rename_all = "snake_case")]
234#[non_exhaustive]
235pub enum ResourceKind {
236 #[default]
238 All,
239 Literal,
241 Prefix,
243}
244
245#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
247pub struct ResourcePattern {
248 #[serde(default)]
249 pub kind: ResourceKind,
250 #[serde(default, skip_serializing_if = "String::is_empty")]
251 pub value: String,
252}
253
254impl ResourcePattern {
255 pub fn all() -> Self {
257 Self::default()
258 }
259
260 pub fn literal(value: impl Into<String>) -> Self {
262 Self {
263 kind: ResourceKind::Literal,
264 value: value.into(),
265 }
266 }
267
268 pub fn prefix(value: impl Into<String>) -> Self {
270 Self {
271 kind: ResourceKind::Prefix,
272 value: value.into(),
273 }
274 }
275
276 pub fn matches(&self, resource: Option<&str>) -> bool {
279 match (self.kind, resource) {
280 (ResourceKind::All, _) => true,
281 (ResourceKind::Literal, Some(r)) => r == self.value,
282 (ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
283 (_, None) => false,
284 }
285 }
286}
287
288#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
291pub struct Grant {
292 pub effect: Effect,
293 pub feature: Feature,
294 pub action: Action,
295 #[serde(default)]
296 pub resource: ResourcePattern,
297}
298
299#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
302pub struct Role {
303 pub name: String,
304 pub grants: Vec<Grant>,
305}
306
307#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
309pub struct RoleBinding {
310 pub user_id: u32,
311 pub roles: Vec<String>,
312}
313
314pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
321 crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
322}
323
324pub fn validate_role(role: &Role) -> Result<(), InvalidError> {
330 validate_role_name(&role.name)?;
331 match role.grants.iter().find(|grant| {
332 matches!(
333 grant.feature,
334 Feature::Memory | Feature::Agent | Feature::Workflow | Feature::Unrecognized
335 )
336 }) {
337 Some(grant) => Err(InvalidError::new(format!(
338 "no request is authorized against the `{}` feature, so a grant on it has no effect",
339 grant.feature
340 ))),
341 None => Ok(()),
342 }
343}
344
345pub fn validate_mutation_id(mutation_id: &str) -> Result<(), InvalidError> {
348 if mutation_id.is_empty() || mutation_id.len() > MAX_AUTHZ_MUTATION_ID_BYTES {
349 return Err(InvalidError::new(format!(
350 "mutation id must be 1 to {MAX_AUTHZ_MUTATION_ID_BYTES} bytes"
351 )));
352 }
353 if !mutation_id
354 .bytes()
355 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
356 {
357 return Err(InvalidError::new(
358 "mutation id may hold only ASCII letters, digits and '-'",
359 ));
360 }
361 Ok(())
362}
363
364pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
368 let pair = match code {
369 AGDX_QUERY_CODE => (Feature::Query, Action::Read),
370 AGDX_GET_PROJECTION_CODE
371 | AGDX_LIST_PROJECTIONS_CODE
372 | AGDX_GET_SCHEMA_CODE
373 | AGDX_LIST_SCHEMAS_CODE
374 | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
375 AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
376 AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
377 (Feature::Kv, Action::Read)
378 }
379 AGDX_KV_SET_CODE
384 | AGDX_KV_CAS_CODE
385 | AGDX_KV_CAS_FENCED_CODE
386 | AGDX_KV_PATCH_CODE
387 | AGDX_KV_EXPIRE_CODE
388 | AGDX_KV_COPY_CODE
389 | AGDX_KV_MOVE_CODE => (Feature::Kv, Action::Write),
390 AGDX_KV_LEASE_CODE | AGDX_KV_LEASE_RENEW_CODE | AGDX_KV_RELEASE_CODE => {
396 (Feature::KvLease, Action::Admin)
397 }
398 AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
399 AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
400 AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
401 AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
402 AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
403 AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
404 AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
405 AGDX_SESSION_GET_CODE
406 | AGDX_SESSION_LIST_CODE
407 | AGDX_SESSION_EVENTS_CODE
408 | AGDX_SESSION_STATE_CODE
409 | AGDX_SESSION_LINKS_CODE
410 | AGDX_SESSION_SOURCES_CODE
411 | AGDX_SESSION_CHANGES_CODE => (Feature::Session, Action::Read),
412 AGDX_GET_FILTER_CODE
413 | AGDX_LIST_FILTERS_CODE
414 | AGDX_LIST_FILTER_REVISIONS_CODE
415 | AGDX_GET_FILTER_BINDING_CODE
416 | AGDX_LIST_FILTER_BINDINGS_CODE
417 | AGDX_FILTER_OPERATION_CODE => (Feature::Filter, Action::Read),
418 _ => return None,
419 };
420 Some(pair)
421}
422
423pub const ACTION_COUNT: usize = 5;
430
431pub fn action_index(feature: Feature, action: Action) -> usize {
436 feature as usize * ACTION_COUNT + action as usize
437}
438
439const _: () = {
447 assert!(
448 <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
449 "ACTION_COUNT must equal the number of Action variants"
450 );
451 assert!(
452 <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 128,
453 "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 128 bits"
454 );
455};
456
457pub fn grants_allow(
461 grants: &[Grant],
462 feature: Feature,
463 action: Action,
464 resource: Option<&str>,
465) -> bool {
466 let mut allowed = false;
467 for grant in grants {
468 if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
469 match grant.effect {
470 Effect::Deny => return false,
471 Effect::Allow => allowed = true,
472 }
473 }
474 }
475 allowed
476}
477
478pub fn delegated_allow(
482 agent: &[Grant],
483 user: &[Grant],
484 feature: Feature,
485 action: Action,
486 resource: Option<&str>,
487) -> bool {
488 grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
489}
490
491#[derive(Clone, Debug, Serialize, Deserialize)]
493pub struct WhoamiReq {
494 pub v: u32,
495}
496
497#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
499pub struct WhoamiReply {
500 pub v: u32,
501 pub roles: Vec<String>,
502 pub grants: Vec<Grant>,
503}
504
505#[derive(Clone, Debug, Serialize, Deserialize)]
508pub struct ListRolesReq {
509 pub v: u32,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub name_prefix: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub search: Option<String>,
514}
515
516#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
518pub struct ListRolesReply {
519 pub v: u32,
520 pub roles: Vec<Role>,
521}
522
523#[derive(Clone, Debug, Serialize, Deserialize)]
525pub struct GetRoleReq {
526 pub v: u32,
527 pub name: String,
528}
529
530#[derive(Clone, Debug, Serialize, Deserialize)]
532pub struct GetBindingsReq {
533 pub v: u32,
534 pub user_id: u32,
535}
536
537#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
539pub struct BindingsReply {
540 pub v: u32,
541 pub roles: Vec<String>,
542 pub revision: u64,
546}
547
548#[derive(Clone, Debug, Serialize, Deserialize)]
550pub struct DefineRoleReq {
551 pub v: u32,
552 pub role: Role,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub mutation_id: Option<String>,
555}
556
557#[derive(Clone, Debug, Serialize, Deserialize)]
559pub struct DeleteRoleReq {
560 pub v: u32,
561 pub name: String,
562 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub mutation_id: Option<String>,
564}
565
566#[derive(Clone, Debug, Serialize, Deserialize)]
568pub struct BindRolesReq {
569 pub v: u32,
570 pub user_id: u32,
571 pub roles: Vec<String>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub expect_revision: Option<u64>,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub mutation_id: Option<String>,
581}
582
583#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
585#[serde(rename_all = "snake_case")]
586pub enum AuthzSubject {
587 Role(String),
589 Binding { user_id: u32 },
591 All,
593 Stream(String),
596}
597
598#[derive(Clone, Debug, Serialize, Deserialize)]
601pub struct AuthzHistoryReq {
602 pub v: u32,
603 pub subject: AuthzSubject,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub after_revision: Option<u64>,
606 pub limit: u32,
607}
608
609#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
611#[serde(rename_all = "snake_case")]
612pub enum AuthzEventKind {
613 RoleDefined(String),
615 RoleDeleted(String),
617 RolesBound { user_id: u32, roles: Vec<String> },
619 TenancySet(Tenancy),
621}
622
623#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
625pub struct AuthzEvent {
626 pub revision: u64,
627 pub actor: String,
628 pub at_micros: u64,
629 pub op: AuthzEventKind,
630 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub stream: Option<String>,
634}
635
636#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
638pub struct AuthzHistoryReply {
639 pub v: u32,
640 pub events: Vec<AuthzEvent>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub next_after_revision: Option<u64>,
643}
644
645#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
647#[non_exhaustive]
648pub enum AuthzReply {
649 Ok,
651 Whoami(WhoamiReply),
653 Roles(ListRolesReply),
655 Role(Option<Role>),
657 Bindings(BindingsReply),
659 History(AuthzHistoryReply),
661 Err(AuthzError),
662}
663
664#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
666#[non_exhaustive]
667pub enum AuthzError {
668 #[error("authz not supported: {0}")]
669 Unsupported(String),
670 #[error("unauthorized")]
671 Unauthorized,
672 #[error("unknown role: {0}")]
673 UnknownRole(String),
674 #[error("invalid role name: {0}")]
676 InvalidName(String),
677 #[error("invalid grant: {0}")]
680 InvalidGrant(String),
681 #[error("revision conflict: current is {current_revision}")]
685 Conflict { current_revision: u64 },
686 #[error("unsupported authz op version (expected {expected}, got {got})")]
687 Version { expected: u32, got: u32 },
688 #[error("refused by stream tenancy: {0}")]
692 TenancyViolation(String),
693 #[error("mutation id conflict: {0}")]
696 MutationConflict(String),
697 #[error("authorization receipt capacity exhausted: {0}")]
700 ResourceLimit(String),
701}
702
703#[cfg(all(test, feature = "cbor"))]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn given_grants_on_unenforced_features_when_a_role_is_validated_then_should_refuse_them() {
709 let grant = |feature| Grant {
710 effect: Effect::Allow,
711 feature,
712 action: Action::Read,
713 resource: ResourcePattern::all(),
714 };
715 let role = |grants| Role {
716 name: "reader".to_owned(),
717 grants,
718 };
719 for feature in [
720 Feature::Memory,
721 Feature::Agent,
722 Feature::Workflow,
723 Feature::Unrecognized,
724 ] {
725 assert!(
726 validate_role(&role(vec![grant(Feature::Kv), grant(feature)])).is_err(),
727 "{feature}"
728 );
729 }
730 validate_role(&role(vec![grant(Feature::Kv), grant(Feature::Session)]))
731 .expect("enforced features validate");
732 assert!(
733 validate_role(&Role {
734 name: "bad name".to_owned(),
735 grants: Vec::new(),
736 })
737 .is_err()
738 );
739 }
740
741 #[test]
742 fn given_role_grants_when_attributed_then_should_name_one_stream_or_none() {
743 let allow = |kind: ResourceKind, value: &str| Grant {
744 effect: Effect::Allow,
745 feature: Feature::Kv,
746 action: Action::Read,
747 resource: ResourcePattern {
748 kind,
749 value: value.to_owned(),
750 },
751 };
752 assert_eq!(
753 grants_stream(&[
754 allow(ResourceKind::Prefix, "stream:acme/"),
755 allow(ResourceKind::Literal, "stream:acme/cart"),
756 ]),
757 Some("acme".to_owned())
758 );
759 assert_eq!(
760 grants_stream(&[
761 allow(ResourceKind::Prefix, "stream:acme/"),
762 allow(ResourceKind::Literal, "stream:other/cart"),
763 ]),
764 None
765 );
766 assert_eq!(grants_stream(&[allow(ResourceKind::All, "")]), None);
767 assert_eq!(
768 grants_stream(&[allow(ResourceKind::Prefix, "stream:acme")]),
769 None
770 );
771 assert_eq!(grants_stream(&[]), None);
772 }
773
774 #[test]
775 fn given_a_tenancy_event_when_round_tripped_then_should_keep_it_and_spanning_should_follow_the_pattern()
776 {
777 let event = AuthzEventKind::TenancySet(Tenancy::Stream);
778 let bytes = crate::framing::encode_named(&event).expect("encodes");
779 assert_eq!(
780 crate::framing::decode_named::<AuthzEventKind>(&bytes).expect("decodes"),
781 event
782 );
783 let allow = |resource| Grant {
784 effect: Effect::Allow,
785 feature: Feature::Kv,
786 action: Action::Read,
787 resource,
788 };
789 assert!(grant_spans_streams(&allow(ResourcePattern::all())));
790 assert!(grant_spans_streams(&allow(ResourcePattern::prefix(
791 "orders"
792 ))));
793 assert!(!grant_spans_streams(&allow(ResourcePattern::prefix(
794 "stream:a/"
795 ))));
796 assert!(!grant_spans_streams(&allow(ResourcePattern::literal("x"))));
797 for spanning in [
798 ResourcePattern::prefix("stream:"),
799 ResourcePattern::prefix("stream:acme"),
800 ResourcePattern::prefix("stream:/notes"),
801 ResourcePattern::prefix("acme/"),
802 ResourcePattern::prefix(""),
803 ] {
804 assert!(
805 grant_spans_streams(&allow(spanning.clone())),
806 "{spanning:?} reaches more than one stream"
807 );
808 }
809 for inside in [
810 ResourcePattern::literal("stream:acme"),
811 ResourcePattern::literal("stream:acme/notes"),
812 ResourcePattern::prefix("stream:acme/agent."),
813 ] {
814 assert!(
815 !grant_spans_streams(&allow(inside.clone())),
816 "{inside:?} stays inside one stream"
817 );
818 }
819 assert!(!grant_spans_streams(&Grant {
820 effect: Effect::Deny,
821 ..allow(ResourcePattern::all())
822 }));
823 assert!(Tenancy::default().is_deployment());
824 }
825 use crate::framing::{decode_named, encode_named};
826
827 #[test]
828 fn given_a_tenancy_violation_when_round_tripped_then_should_keep_the_reason() {
829 let reply = AuthzReply::Err(AuthzError::TenancyViolation("prefix acme".to_owned()));
830 let back: AuthzReply =
831 decode_named(&encode_named(&reply).expect("reply serializes")).expect("deserializes");
832 assert!(matches!(
833 back,
834 AuthzReply::Err(AuthzError::TenancyViolation(reason)) if reason == "prefix acme"
835 ));
836 }
837
838 #[test]
839 fn given_a_local_name_when_scoped_then_should_prefix_the_stream() {
840 assert_eq!(
841 scoped_resource("acme", "agent.keys"),
842 "stream:acme/agent.keys"
843 );
844 assert_eq!(scoped_resource("acme", "a/b"), "stream:acme/a/b");
845 }
846
847 #[test]
848 fn given_a_scoped_name_when_scoped_again_then_should_return_it_unchanged() {
849 let scoped = scoped_resource("acme", "sessions");
850 assert_eq!(scoped_resource("acme", &scoped), scoped);
851 assert_eq!(
852 scoped_resource("other", "stream:acme/sessions"),
853 "stream:acme/sessions"
854 );
855 }
856
857 #[test]
858 fn given_scoped_names_when_split_then_should_return_stream_and_local_part() {
859 assert_eq!(
860 split_scoped_resource("stream:acme/agent.keys"),
861 Some(("acme", "agent.keys"))
862 );
863 assert_eq!(
864 split_scoped_resource("stream:acme/a/b"),
865 Some(("acme", "a/b"))
866 );
867 assert_eq!(
868 split_scoped_resource(&scoped_resource("acme", "graph")),
869 Some(("acme", "graph"))
870 );
871 }
872
873 #[test]
874 fn given_unscoped_names_when_split_then_should_return_none() {
875 assert_eq!(split_scoped_resource("agent.keys"), None);
876 assert_eq!(split_scoped_resource("stream:acme"), None);
877 assert_eq!(split_scoped_resource("stream:/local"), None);
878 assert_eq!(split_scoped_resource("stream:acme/"), None);
879 assert_eq!(split_scoped_resource("streams:acme/local"), None);
880 }
881
882 #[test]
883 fn given_a_role_when_round_tripped_then_should_preserve_grants() {
884 let role = Role {
885 name: "kv-reader".to_string(),
886 grants: vec![
887 Grant {
888 effect: Effect::Allow,
889 feature: Feature::Kv,
890 action: Action::Read,
891 resource: ResourcePattern::prefix("agent-abc/"),
892 },
893 Grant {
894 effect: Effect::Deny,
895 feature: Feature::Kv,
896 action: Action::Read,
897 resource: ResourcePattern::literal("agent-abc/secret"),
898 },
899 ],
900 };
901 let bytes = encode_named(&role).expect("role serializes");
902 let back: Role = decode_named(&bytes).expect("role deserializes");
903 assert_eq!(back, role);
904 }
905
906 #[test]
907 fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
908 assert!(validate_role_name("kv-reader").is_ok());
909 assert!(validate_role_name("ops.admin_2").is_ok());
910 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
911 assert!(validate_role_name("").is_err(), "empty");
912 assert!(validate_role_name("bad name").is_err(), "space");
913 assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
914 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
915 }
916
917 #[test]
918 fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
919 assert!(ResourcePattern::all().matches(Some("anything")));
920 assert!(ResourcePattern::all().matches(None));
921 assert!(ResourcePattern::literal("ns").matches(Some("ns")));
922 assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
923 assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
924 assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
925 assert!(!ResourcePattern::literal("ns").matches(None));
928 assert!(!ResourcePattern::prefix("agent-").matches(None));
929 }
930
931 #[test]
932 fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
933 let allow = |feature, action, resource| Grant {
934 effect: Effect::Allow,
935 feature,
936 action,
937 resource,
938 };
939 let agent = vec![
942 allow(Feature::Kv, Action::Read, ResourcePattern::all()),
943 allow(Feature::Kv, Action::Write, ResourcePattern::all()),
944 ];
945 let user = vec![allow(
946 Feature::Kv,
947 Action::Read,
948 ResourcePattern::prefix("shared/"),
949 )];
950 assert!(delegated_allow(
951 &agent,
952 &user,
953 Feature::Kv,
954 Action::Read,
955 Some("shared/x")
956 ));
957 assert!(!delegated_allow(
959 &agent,
960 &user,
961 Feature::Kv,
962 Action::Read,
963 Some("private/x")
964 ));
965 assert!(!delegated_allow(
967 &agent,
968 &user,
969 Feature::Kv,
970 Action::Write,
971 Some("shared/x")
972 ));
973 assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
975 }
976
977 #[test]
978 fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
979 assert_eq!(
980 feature_action(AGDX_KV_GET_CODE),
981 Some((Feature::Kv, Action::Read))
982 );
983 assert_eq!(
984 feature_action(AGDX_KV_SET_CODE),
985 Some((Feature::Kv, Action::Write))
986 );
987 assert_eq!(
988 feature_action(AGDX_KV_DELETE_CODE),
989 Some((Feature::Kv, Action::Delete))
990 );
991 assert_eq!(
994 feature_action(AGDX_KV_LEASE_CODE),
995 Some((Feature::KvLease, Action::Admin))
996 );
997 assert_eq!(
998 feature_action(AGDX_KV_LEASE_RENEW_CODE),
999 Some((Feature::KvLease, Action::Admin))
1000 );
1001 assert_eq!(
1002 feature_action(AGDX_KV_RELEASE_CODE),
1003 Some((Feature::KvLease, Action::Admin))
1004 );
1005 assert_eq!(
1006 feature_action(AGDX_KV_CAS_FENCED_CODE),
1007 Some((Feature::Kv, Action::Write))
1008 );
1009 assert_eq!(
1010 feature_action(AGDX_REGISTER_SCHEMA_CODE),
1011 Some((Feature::Projection, Action::Admin))
1012 );
1013 assert_eq!(
1014 feature_action(AGDX_QUERY_CODE),
1015 Some((Feature::Query, Action::Read))
1016 );
1017 assert_eq!(
1018 feature_action(AGDX_GRAPH_UPSERT_CODE),
1019 Some((Feature::Graph, Action::Write))
1020 );
1021 assert_eq!(feature_action(AGDX_HELLO_CODE), None);
1023 assert_eq!(feature_action(AGDX_BATCH_CODE), None);
1024 assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
1025 assert_eq!(feature_action(AGDX_CONTROL_PUBLISH_CODE), None);
1026 }
1027
1028 #[test]
1029 fn given_feature_action_pairs_when_indexed_then_should_fit_a_u128_mask() {
1030 use strum::VariantArray;
1031 let mut seen = std::collections::HashSet::new();
1032 for &feature in Feature::VARIANTS {
1033 for &action in Action::VARIANTS {
1034 let index = action_index(feature, action);
1035 assert!(index < 128, "index {index} must fit a u128 mask");
1036 assert!(seen.insert(index), "index {index} collided");
1037 }
1038 }
1039 assert_eq!(action_index(Feature::Destination, Action::Read), 40);
1040 assert_eq!(action_index(Feature::Checkpoint, Action::Admin), 48);
1041 assert_eq!(action_index(Feature::Session, Action::Read), 70);
1042 }
1043
1044 #[test]
1045 fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
1046 let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
1049 let grant: Grant =
1050 serde_json::from_str(json).expect("an unknown feature/action still decodes");
1051 assert_eq!(grant.feature, Feature::Unrecognized);
1052 assert_eq!(grant.action, Action::Unrecognized);
1053 assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
1057 assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
1061 assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
1062 assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
1063 assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
1064 }
1065
1066 #[test]
1067 fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
1068 let reply = AuthzReply::Whoami(WhoamiReply {
1069 v: AUTHZ_OP_VERSION,
1070 roles: vec!["admin".to_string()],
1071 grants: vec![Grant {
1072 effect: Effect::Allow,
1073 feature: Feature::Kv,
1074 action: Action::Write,
1075 resource: ResourcePattern::all(),
1076 }],
1077 });
1078 let bytes = encode_named(&reply).expect("reply serializes");
1079 let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
1080 assert_eq!(back, reply);
1081 }
1082}