1use crate::codes::*;
2use crate::error::InvalidError;
3use crate::limits::MAX_ROLE_NAME_BYTES;
4use crate::{checkpoint::CheckpointRequestId, destination::DestinationId};
5use serde::{Deserialize, Serialize};
6
7#[derive(
9 Clone,
10 Copy,
11 Debug,
12 Default,
13 PartialEq,
14 Eq,
15 Serialize,
16 Deserialize,
17 strum::Display,
18 strum::EnumString,
19 strum::VariantArray,
20)]
21#[strum(serialize_all = "snake_case")]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum Effect {
25 #[default]
26 Allow,
27 Deny,
28}
29
30#[derive(
33 Clone,
34 Copy,
35 Debug,
36 PartialEq,
37 Eq,
38 Hash,
39 Serialize,
40 Deserialize,
41 strum::Display,
42 strum::EnumString,
43 strum::VariantArray,
44)]
45#[strum(serialize_all = "snake_case")]
46#[serde(rename_all = "snake_case")]
47#[non_exhaustive]
48pub enum Feature {
49 Kv,
50 Memory,
51 Projection,
52 Fork,
53 Graph,
54 Query,
55 Agent,
56 Workflow,
57 Destination,
58 Checkpoint,
59 Authz,
62 KvLease,
69 KvFence,
73 Filter,
77 Session,
79 #[serde(other)]
87 Unrecognized,
88}
89
90pub const STREAM_RESOURCE_PREFIX: &str = "stream:";
93
94pub fn stream_resource(stream: &str) -> String {
96 format!("{STREAM_RESOURCE_PREFIX}{stream}")
97}
98
99pub fn scoped_resource(stream: &str, local: &str) -> String {
104 if local.starts_with(STREAM_RESOURCE_PREFIX) {
105 local.to_owned()
106 } else {
107 format!("{STREAM_RESOURCE_PREFIX}{stream}/{local}")
108 }
109}
110
111pub fn split_scoped_resource(name: &str) -> Option<(&str, &str)> {
115 let (stream, local) = name.strip_prefix(STREAM_RESOURCE_PREFIX)?.split_once('/')?;
116 (!stream.is_empty() && !local.is_empty()).then_some((stream, local))
117}
118
119pub const SUPERVISOR_ASSERTION_VERSION: u32 = 1;
120pub const SUPERVISOR_ASSERTION_DOMAIN: &[u8] = b"agdx.supervisor-actor.v1";
121pub const MAX_SUPERVISOR_ASSERTION_TTL_MICROS: u64 = 5 * 60 * 1_000_000;
122pub const SUPERVISOR_ASSERTION_KEY_ID_BYTES: usize = 8;
123pub const SUPERVISOR_ASSERTION_SIGNATURE_BYTES: usize = 64;
124
125#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SupervisorActorClaims {
128 pub v: u32,
129 pub request_id: CheckpointRequestId,
130 pub deployment_id: u32,
131 pub cloud_user_id: u32,
132 pub action: SupervisorAssertionAction,
133 pub destination_id: DestinationId,
134 pub destination_generation: u64,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub expected_revision: Option<u64>,
137 pub issued_at_micros: u64,
138 pub expires_at_micros: u64,
139}
140
141#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub struct SupervisorActorAssertion {
144 pub claims: SupervisorActorClaims,
145 #[serde(with = "crate::encoding::bin_bytes")]
146 pub key_id: Vec<u8>,
147 #[serde(with = "crate::encoding::bin_bytes")]
148 pub signature: Vec<u8>,
149}
150
151#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153#[non_exhaustive]
154pub enum SupervisorAssertionAction {
155 AcceptRetentionGap,
156 SupersedeGeneration,
157 RecordRepair,
158}
159
160impl SupervisorActorAssertion {
161 pub fn validate(&self) -> Result<(), InvalidError> {
162 let claims = &self.claims;
163 if claims.v != SUPERVISOR_ASSERTION_VERSION {
164 return Err(InvalidError::new(format!(
165 "supervisor assertion version must be {SUPERVISOR_ASSERTION_VERSION}"
166 )));
167 }
168 if claims.request_id.as_u128() == 0
169 || claims.deployment_id == 0
170 || claims.cloud_user_id == 0
171 || claims.destination_id.as_u128() == 0
172 || claims.destination_generation == 0
173 {
174 return Err(InvalidError::new(
175 "supervisor assertion identities must be nonzero",
176 ));
177 }
178 if claims.issued_at_micros == 0
179 || claims.expires_at_micros <= claims.issued_at_micros
180 || claims.expires_at_micros - claims.issued_at_micros
181 > MAX_SUPERVISOR_ASSERTION_TTL_MICROS
182 {
183 return Err(InvalidError::new(
184 "supervisor assertion validity window is invalid",
185 ));
186 }
187 if self.key_id.len() != SUPERVISOR_ASSERTION_KEY_ID_BYTES
188 || self.signature.len() != SUPERVISOR_ASSERTION_SIGNATURE_BYTES
189 {
190 return Err(InvalidError::new(
191 "supervisor assertion key id or signature length is invalid",
192 ));
193 }
194 Ok(())
195 }
196}
197
198#[derive(
200 Clone,
201 Copy,
202 Debug,
203 PartialEq,
204 Eq,
205 Hash,
206 Serialize,
207 Deserialize,
208 strum::Display,
209 strum::EnumString,
210 strum::VariantArray,
211)]
212#[strum(serialize_all = "snake_case")]
213#[serde(rename_all = "snake_case")]
214#[non_exhaustive]
215pub enum Action {
216 Read,
217 Write,
218 Delete,
219 Admin,
220 #[serde(other)]
225 Unrecognized,
226}
227
228#[derive(
230 Clone,
231 Copy,
232 Debug,
233 Default,
234 PartialEq,
235 Eq,
236 Serialize,
237 Deserialize,
238 strum::Display,
239 strum::EnumString,
240 strum::VariantArray,
241)]
242#[strum(serialize_all = "snake_case")]
243#[serde(rename_all = "snake_case")]
244#[non_exhaustive]
245pub enum ResourceKind {
246 #[default]
248 All,
249 Literal,
251 Prefix,
253}
254
255#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
257pub struct ResourcePattern {
258 #[serde(default)]
259 pub kind: ResourceKind,
260 #[serde(default, skip_serializing_if = "String::is_empty")]
261 pub value: String,
262}
263
264impl ResourcePattern {
265 pub fn all() -> Self {
267 Self::default()
268 }
269
270 pub fn literal(value: impl Into<String>) -> Self {
272 Self {
273 kind: ResourceKind::Literal,
274 value: value.into(),
275 }
276 }
277
278 pub fn prefix(value: impl Into<String>) -> Self {
280 Self {
281 kind: ResourceKind::Prefix,
282 value: value.into(),
283 }
284 }
285
286 pub fn matches(&self, resource: Option<&str>) -> bool {
289 match (self.kind, resource) {
290 (ResourceKind::All, _) => true,
291 (ResourceKind::Literal, Some(r)) => r == self.value,
292 (ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
293 (_, None) => false,
294 }
295 }
296}
297
298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
301pub struct Grant {
302 pub effect: Effect,
303 pub feature: Feature,
304 pub action: Action,
305 #[serde(default)]
306 pub resource: ResourcePattern,
307}
308
309#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
312pub struct Role {
313 pub name: String,
314 pub grants: Vec<Grant>,
315}
316
317#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
319pub struct RoleBinding {
320 pub user_id: u32,
321 pub roles: Vec<String>,
322}
323
324pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
331 crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
332}
333
334pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
338 let pair = match code {
339 AGDX_QUERY_CODE | AGDX_QUERY_PAGE_CODE | AGDX_QUERY_STATUS_CODE => {
340 (Feature::Query, Action::Read)
341 }
342 AGDX_QUERY_CANCEL_CODE => (Feature::Query, Action::Delete),
343 AGDX_DESTINATION_GET_CODE | AGDX_DESTINATION_LIST_CODE | AGDX_QUERY_ROUTE_LIST_CODE => {
344 (Feature::Destination, Action::Read)
345 }
346 AGDX_GET_PROJECTION_CODE
347 | AGDX_LIST_PROJECTIONS_CODE
348 | AGDX_GET_SCHEMA_CODE
349 | AGDX_LIST_SCHEMAS_CODE
350 | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
351 AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
352 AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
353 (Feature::Kv, Action::Read)
354 }
355 AGDX_KV_SET_CODE
360 | AGDX_KV_CAS_CODE
361 | AGDX_KV_CAS_FENCED_CODE
362 | AGDX_KV_PATCH_CODE
363 | AGDX_KV_EXPIRE_CODE
364 | AGDX_KV_COPY_CODE
365 | AGDX_KV_MOVE_CODE => (Feature::Kv, Action::Write),
366 AGDX_KV_LEASE_CODE | AGDX_KV_LEASE_RENEW_CODE | AGDX_KV_RELEASE_CODE => {
372 (Feature::KvLease, Action::Admin)
373 }
374 AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
375 AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
376 AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
377 AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
378 AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
379 AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
380 AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
381 AGDX_SESSION_GET_CODE
382 | AGDX_SESSION_LIST_CODE
383 | AGDX_SESSION_EVENTS_CODE
384 | AGDX_SESSION_STATE_CODE
385 | AGDX_SESSION_LINKS_CODE
386 | AGDX_SESSION_SOURCES_CODE
387 | AGDX_SESSION_CHANGES_CODE => (Feature::Session, Action::Read),
388 AGDX_GET_FILTER_CODE
389 | AGDX_LIST_FILTERS_CODE
390 | AGDX_LIST_FILTER_REVISIONS_CODE
391 | AGDX_GET_FILTER_BINDING_CODE
392 | AGDX_LIST_FILTER_BINDINGS_CODE
393 | AGDX_FILTER_OPERATION_CODE => (Feature::Filter, Action::Read),
394 _ => return None,
395 };
396 Some(pair)
397}
398
399pub const ACTION_COUNT: usize = 5;
406
407pub fn action_index(feature: Feature, action: Action) -> usize {
412 feature as usize * ACTION_COUNT + action as usize
413}
414
415const _: () = {
423 assert!(
424 <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
425 "ACTION_COUNT must equal the number of Action variants"
426 );
427 assert!(
428 <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 128,
429 "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 128 bits"
430 );
431};
432
433pub fn grants_allow(
437 grants: &[Grant],
438 feature: Feature,
439 action: Action,
440 resource: Option<&str>,
441) -> bool {
442 let mut allowed = false;
443 for grant in grants {
444 if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
445 match grant.effect {
446 Effect::Deny => return false,
447 Effect::Allow => allowed = true,
448 }
449 }
450 }
451 allowed
452}
453
454pub fn delegated_allow(
458 agent: &[Grant],
459 user: &[Grant],
460 feature: Feature,
461 action: Action,
462 resource: Option<&str>,
463) -> bool {
464 grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
465}
466
467#[derive(Clone, Debug, Serialize, Deserialize)]
469pub struct WhoamiReq {
470 pub v: u32,
471}
472
473#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
475pub struct WhoamiReply {
476 pub v: u32,
477 pub roles: Vec<String>,
478 pub grants: Vec<Grant>,
479}
480
481#[derive(Clone, Debug, Serialize, Deserialize)]
484pub struct ListRolesReq {
485 pub v: u32,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub name_prefix: Option<String>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub search: Option<String>,
490}
491
492#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
494pub struct ListRolesReply {
495 pub v: u32,
496 pub roles: Vec<Role>,
497}
498
499#[derive(Clone, Debug, Serialize, Deserialize)]
501pub struct GetRoleReq {
502 pub v: u32,
503 pub name: String,
504}
505
506#[derive(Clone, Debug, Serialize, Deserialize)]
508pub struct GetBindingsReq {
509 pub v: u32,
510 pub user_id: u32,
511}
512
513#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
515pub struct BindingsReply {
516 pub v: u32,
517 pub roles: Vec<String>,
518}
519
520#[derive(Clone, Debug, Serialize, Deserialize)]
522pub struct DefineRoleReq {
523 pub v: u32,
524 pub role: Role,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub mutation_id: Option<String>,
527}
528
529#[derive(Clone, Debug, Serialize, Deserialize)]
531pub struct DeleteRoleReq {
532 pub v: u32,
533 pub name: String,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub mutation_id: Option<String>,
536}
537
538#[derive(Clone, Debug, Serialize, Deserialize)]
540pub struct BindRolesReq {
541 pub v: u32,
542 pub user_id: u32,
543 pub roles: Vec<String>,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub expect_revision: Option<u64>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub mutation_id: Option<String>,
553}
554
555#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "snake_case")]
558pub enum AuthzSubject {
559 Role(String),
561 Binding { user_id: u32 },
563 All,
565}
566
567#[derive(Clone, Debug, Serialize, Deserialize)]
570pub struct AuthzHistoryReq {
571 pub v: u32,
572 pub subject: AuthzSubject,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub after_revision: Option<u64>,
575 pub limit: u32,
576}
577
578#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
580#[serde(rename_all = "snake_case")]
581pub enum AuthzEventKind {
582 RoleDefined(String),
584 RoleDeleted(String),
586 RolesBound { user_id: u32, roles: Vec<String> },
588}
589
590#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
592pub struct AuthzEvent {
593 pub revision: u64,
594 pub actor: String,
595 pub at_micros: u64,
596 pub op: AuthzEventKind,
597}
598
599#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
601pub struct AuthzHistoryReply {
602 pub v: u32,
603 pub events: Vec<AuthzEvent>,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub next_after_revision: Option<u64>,
606}
607
608#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
610#[non_exhaustive]
611pub enum AuthzReply {
612 Ok,
614 Whoami(WhoamiReply),
616 Roles(ListRolesReply),
618 Role(Option<Role>),
620 Bindings(BindingsReply),
622 History(AuthzHistoryReply),
624 Err(AuthzError),
625}
626
627#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
629#[non_exhaustive]
630pub enum AuthzError {
631 #[error("authz not supported: {0}")]
632 Unsupported(String),
633 #[error("unauthorized")]
634 Unauthorized,
635 #[error("unknown role: {0}")]
636 UnknownRole(String),
637 #[error("invalid role name: {0}")]
639 InvalidName(String),
640 #[error("revision conflict: current is {current_revision}")]
644 Conflict { current_revision: u64 },
645 #[error("unsupported authz op version (expected {expected}, got {got})")]
646 Version { expected: u32, got: u32 },
647 #[error("refused by stream tenancy: {0}")]
651 TenancyViolation(String),
652}
653
654#[cfg(all(test, feature = "cbor"))]
655mod tests {
656 use super::*;
657 use crate::framing::{decode_named, encode_named};
658
659 #[test]
660 fn given_a_tenancy_violation_when_round_tripped_then_should_keep_the_reason() {
661 let reply = AuthzReply::Err(AuthzError::TenancyViolation("prefix acme".to_owned()));
662 let back: AuthzReply =
663 decode_named(&encode_named(&reply).expect("reply serializes")).expect("deserializes");
664 assert!(matches!(
665 back,
666 AuthzReply::Err(AuthzError::TenancyViolation(reason)) if reason == "prefix acme"
667 ));
668 }
669
670 #[test]
671 fn given_a_local_name_when_scoped_then_should_prefix_the_stream() {
672 assert_eq!(
673 scoped_resource("acme", "agent.keys"),
674 "stream:acme/agent.keys"
675 );
676 assert_eq!(scoped_resource("acme", "a/b"), "stream:acme/a/b");
677 }
678
679 #[test]
680 fn given_a_scoped_name_when_scoped_again_then_should_return_it_unchanged() {
681 let scoped = scoped_resource("acme", "sessions");
682 assert_eq!(scoped_resource("acme", &scoped), scoped);
683 assert_eq!(
684 scoped_resource("other", "stream:acme/sessions"),
685 "stream:acme/sessions"
686 );
687 }
688
689 #[test]
690 fn given_scoped_names_when_split_then_should_return_stream_and_local_part() {
691 assert_eq!(
692 split_scoped_resource("stream:acme/agent.keys"),
693 Some(("acme", "agent.keys"))
694 );
695 assert_eq!(
696 split_scoped_resource("stream:acme/a/b"),
697 Some(("acme", "a/b"))
698 );
699 assert_eq!(
700 split_scoped_resource(&scoped_resource("acme", "graph")),
701 Some(("acme", "graph"))
702 );
703 }
704
705 #[test]
706 fn given_unscoped_names_when_split_then_should_return_none() {
707 assert_eq!(split_scoped_resource("agent.keys"), None);
708 assert_eq!(split_scoped_resource("stream:acme"), None);
709 assert_eq!(split_scoped_resource("stream:/local"), None);
710 assert_eq!(split_scoped_resource("stream:acme/"), None);
711 assert_eq!(split_scoped_resource("streams:acme/local"), None);
712 }
713
714 #[test]
715 fn given_a_role_when_round_tripped_then_should_preserve_grants() {
716 let role = Role {
717 name: "kv-reader".to_string(),
718 grants: vec![
719 Grant {
720 effect: Effect::Allow,
721 feature: Feature::Kv,
722 action: Action::Read,
723 resource: ResourcePattern::prefix("agent-abc/"),
724 },
725 Grant {
726 effect: Effect::Deny,
727 feature: Feature::Kv,
728 action: Action::Read,
729 resource: ResourcePattern::literal("agent-abc/secret"),
730 },
731 ],
732 };
733 let bytes = encode_named(&role).expect("role serializes");
734 let back: Role = decode_named(&bytes).expect("role deserializes");
735 assert_eq!(back, role);
736 }
737
738 #[test]
739 fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
740 assert!(validate_role_name("kv-reader").is_ok());
741 assert!(validate_role_name("ops.admin_2").is_ok());
742 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
743 assert!(validate_role_name("").is_err(), "empty");
744 assert!(validate_role_name("bad name").is_err(), "space");
745 assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
746 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
747 }
748
749 #[test]
750 fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
751 assert!(ResourcePattern::all().matches(Some("anything")));
752 assert!(ResourcePattern::all().matches(None));
753 assert!(ResourcePattern::literal("ns").matches(Some("ns")));
754 assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
755 assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
756 assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
757 assert!(!ResourcePattern::literal("ns").matches(None));
760 assert!(!ResourcePattern::prefix("agent-").matches(None));
761 }
762
763 #[test]
764 fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
765 let allow = |feature, action, resource| Grant {
766 effect: Effect::Allow,
767 feature,
768 action,
769 resource,
770 };
771 let agent = vec![
774 allow(Feature::Kv, Action::Read, ResourcePattern::all()),
775 allow(Feature::Kv, Action::Write, ResourcePattern::all()),
776 ];
777 let user = vec![allow(
778 Feature::Kv,
779 Action::Read,
780 ResourcePattern::prefix("shared/"),
781 )];
782 assert!(delegated_allow(
783 &agent,
784 &user,
785 Feature::Kv,
786 Action::Read,
787 Some("shared/x")
788 ));
789 assert!(!delegated_allow(
791 &agent,
792 &user,
793 Feature::Kv,
794 Action::Read,
795 Some("private/x")
796 ));
797 assert!(!delegated_allow(
799 &agent,
800 &user,
801 Feature::Kv,
802 Action::Write,
803 Some("shared/x")
804 ));
805 assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
807 }
808
809 #[test]
810 fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
811 assert_eq!(
812 feature_action(AGDX_KV_GET_CODE),
813 Some((Feature::Kv, Action::Read))
814 );
815 assert_eq!(
816 feature_action(AGDX_KV_SET_CODE),
817 Some((Feature::Kv, Action::Write))
818 );
819 assert_eq!(
820 feature_action(AGDX_KV_DELETE_CODE),
821 Some((Feature::Kv, Action::Delete))
822 );
823 assert_eq!(
826 feature_action(AGDX_KV_LEASE_CODE),
827 Some((Feature::KvLease, Action::Admin))
828 );
829 assert_eq!(
830 feature_action(AGDX_KV_LEASE_RENEW_CODE),
831 Some((Feature::KvLease, Action::Admin))
832 );
833 assert_eq!(
834 feature_action(AGDX_KV_RELEASE_CODE),
835 Some((Feature::KvLease, Action::Admin))
836 );
837 assert_eq!(
838 feature_action(AGDX_KV_CAS_FENCED_CODE),
839 Some((Feature::Kv, Action::Write))
840 );
841 assert_eq!(
842 feature_action(AGDX_REGISTER_SCHEMA_CODE),
843 Some((Feature::Projection, Action::Admin))
844 );
845 assert_eq!(
846 feature_action(AGDX_QUERY_CODE),
847 Some((Feature::Query, Action::Read))
848 );
849 assert_eq!(
850 feature_action(AGDX_GRAPH_UPSERT_CODE),
851 Some((Feature::Graph, Action::Write))
852 );
853 assert_eq!(
854 feature_action(AGDX_DESTINATION_GET_CODE),
855 Some((Feature::Destination, Action::Read))
856 );
857 assert_eq!(feature_action(AGDX_CHECKPOINT_CODE), None);
858 assert_eq!(feature_action(AGDX_DESTINATION_HTTP_CODE), None);
859 assert_eq!(feature_action(AGDX_HELLO_CODE), None);
861 assert_eq!(feature_action(AGDX_BATCH_CODE), None);
862 assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
863 assert_eq!(feature_action(AGDX_CONTROL_PUBLISH_CODE), None);
864 }
865
866 #[test]
867 fn given_feature_action_pairs_when_indexed_then_should_fit_a_u128_mask() {
868 use strum::VariantArray;
869 let mut seen = std::collections::HashSet::new();
870 for &feature in Feature::VARIANTS {
871 for &action in Action::VARIANTS {
872 let index = action_index(feature, action);
873 assert!(index < 128, "index {index} must fit a u128 mask");
874 assert!(seen.insert(index), "index {index} collided");
875 }
876 }
877 assert_eq!(action_index(Feature::Destination, Action::Read), 40);
878 assert_eq!(action_index(Feature::Checkpoint, Action::Admin), 48);
879 assert_eq!(action_index(Feature::Session, Action::Read), 70);
880 }
881
882 #[test]
883 fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
884 let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
887 let grant: Grant =
888 serde_json::from_str(json).expect("an unknown feature/action still decodes");
889 assert_eq!(grant.feature, Feature::Unrecognized);
890 assert_eq!(grant.action, Action::Unrecognized);
891 assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
895 assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
899 assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
900 assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
901 assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
902 }
903
904 #[test]
905 fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
906 let reply = AuthzReply::Whoami(WhoamiReply {
907 v: AUTHZ_OP_VERSION,
908 roles: vec!["admin".to_string()],
909 grants: vec![Grant {
910 effect: Effect::Allow,
911 feature: Feature::Kv,
912 action: Action::Write,
913 resource: ResourcePattern::all(),
914 }],
915 });
916 let bytes = encode_named(&reply).expect("reply serializes");
917 let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
918 assert_eq!(back, reply);
919 }
920}