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
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum Tenancy {
124 #[default]
126 Deployment,
127 Stream,
130}
131
132impl Tenancy {
133 pub fn is_deployment(&self) -> bool {
134 *self == Self::Deployment
135 }
136}
137
138pub fn grant_spans_streams(grant: &Grant) -> bool {
142 if grant.effect == Effect::Deny {
143 return false;
144 }
145 match grant.resource.kind {
146 ResourceKind::Literal => false,
147 ResourceKind::Prefix => prefix_stream(&grant.resource.value).is_none(),
148 _ => true,
150 }
151}
152
153pub fn grants_stream(grants: &[Grant]) -> Option<String> {
157 let mut streams = grants
158 .iter()
159 .filter(|grant| grant.effect == Effect::Allow)
160 .map(|grant| match grant.resource.kind {
161 ResourceKind::Literal => split_scoped_resource(&grant.resource.value)
162 .map(|(stream, _)| stream)
163 .or_else(|| grant.resource.value.strip_prefix(STREAM_RESOURCE_PREFIX))
164 .filter(|stream| !stream.is_empty() && !stream.contains('/')),
165 ResourceKind::Prefix => prefix_stream(&grant.resource.value),
166 _ => None,
167 });
168 let first = streams.next()??;
169 streams
170 .all(|stream| stream == Some(first))
171 .then(|| first.to_owned())
172}
173
174fn prefix_stream(prefix: &str) -> Option<&str> {
176 if let Some((stream, _local)) = split_scoped_resource(prefix) {
177 return Some(stream);
178 }
179 prefix
180 .strip_prefix(STREAM_RESOURCE_PREFIX)?
181 .strip_suffix('/')
182 .filter(|stream| !stream.is_empty() && !stream.contains('/'))
183}
184
185pub const OPERATOR_ASSERTION_VERSION: u32 = 1;
186pub const OPERATOR_ASSERTION_DOMAIN: &[u8] = b"agdx.operator-actor.v1";
187pub const MAX_OPERATOR_ASSERTION_TTL_MICROS: u64 = 5 * 60 * 1_000_000;
188pub const OPERATOR_ASSERTION_KEY_ID_BYTES: usize = 8;
189pub const OPERATOR_ASSERTION_SIGNATURE_BYTES: usize = 64;
190
191#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
193pub struct OperatorActorClaims {
194 pub v: u32,
195 pub request_id: CheckpointRequestId,
196 pub deployment_id: u32,
197 pub cloud_user_id: u32,
198 pub action: OperatorAssertionAction,
199 pub destination_id: DestinationId,
200 pub destination_generation: u64,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub expected_revision: Option<u64>,
203 pub issued_at_micros: u64,
204 pub expires_at_micros: u64,
205}
206
207#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
209pub struct OperatorActorAssertion {
210 pub claims: OperatorActorClaims,
211 #[serde(with = "crate::encoding::bin_bytes")]
212 pub key_id: Vec<u8>,
213 #[serde(with = "crate::encoding::bin_bytes")]
214 pub signature: Vec<u8>,
215}
216
217#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "snake_case")]
219#[non_exhaustive]
220pub enum OperatorAssertionAction {
221 AcceptRetentionGap,
222 SupersedeGeneration,
223 RecordRepair,
224}
225
226impl OperatorActorAssertion {
227 pub fn validate(&self) -> Result<(), InvalidError> {
228 let claims = &self.claims;
229 if claims.v != OPERATOR_ASSERTION_VERSION {
230 return Err(InvalidError::new(format!(
231 "operator assertion version must be {OPERATOR_ASSERTION_VERSION}"
232 )));
233 }
234 if claims.request_id.as_u128() == 0
235 || claims.deployment_id == 0
236 || claims.cloud_user_id == 0
237 || claims.destination_id.as_u128() == 0
238 || claims.destination_generation == 0
239 {
240 return Err(InvalidError::new(
241 "operator assertion identities must be nonzero",
242 ));
243 }
244 if claims.issued_at_micros == 0
245 || claims.expires_at_micros <= claims.issued_at_micros
246 || claims.expires_at_micros - claims.issued_at_micros
247 > MAX_OPERATOR_ASSERTION_TTL_MICROS
248 {
249 return Err(InvalidError::new(
250 "operator assertion validity window is invalid",
251 ));
252 }
253 if self.key_id.len() != OPERATOR_ASSERTION_KEY_ID_BYTES
254 || self.signature.len() != OPERATOR_ASSERTION_SIGNATURE_BYTES
255 {
256 return Err(InvalidError::new(
257 "operator assertion key id or signature length is invalid",
258 ));
259 }
260 Ok(())
261 }
262}
263
264#[derive(
266 Clone,
267 Copy,
268 Debug,
269 PartialEq,
270 Eq,
271 Hash,
272 Serialize,
273 Deserialize,
274 strum::Display,
275 strum::EnumString,
276 strum::VariantArray,
277)]
278#[strum(serialize_all = "snake_case")]
279#[serde(rename_all = "snake_case")]
280#[non_exhaustive]
281pub enum Action {
282 Read,
283 Write,
284 Delete,
285 Admin,
286 #[serde(other)]
291 Unrecognized,
292}
293
294#[derive(
296 Clone,
297 Copy,
298 Debug,
299 Default,
300 PartialEq,
301 Eq,
302 Serialize,
303 Deserialize,
304 strum::Display,
305 strum::EnumString,
306 strum::VariantArray,
307)]
308#[strum(serialize_all = "snake_case")]
309#[serde(rename_all = "snake_case")]
310#[non_exhaustive]
311pub enum ResourceKind {
312 #[default]
314 All,
315 Literal,
317 Prefix,
319}
320
321#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
323pub struct ResourcePattern {
324 #[serde(default)]
325 pub kind: ResourceKind,
326 #[serde(default, skip_serializing_if = "String::is_empty")]
327 pub value: String,
328}
329
330impl ResourcePattern {
331 pub fn all() -> Self {
333 Self::default()
334 }
335
336 pub fn literal(value: impl Into<String>) -> Self {
338 Self {
339 kind: ResourceKind::Literal,
340 value: value.into(),
341 }
342 }
343
344 pub fn prefix(value: impl Into<String>) -> Self {
346 Self {
347 kind: ResourceKind::Prefix,
348 value: value.into(),
349 }
350 }
351
352 pub fn matches(&self, resource: Option<&str>) -> bool {
355 match (self.kind, resource) {
356 (ResourceKind::All, _) => true,
357 (ResourceKind::Literal, Some(r)) => r == self.value,
358 (ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
359 (_, None) => false,
360 }
361 }
362}
363
364#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
367pub struct Grant {
368 pub effect: Effect,
369 pub feature: Feature,
370 pub action: Action,
371 #[serde(default)]
372 pub resource: ResourcePattern,
373}
374
375#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
378pub struct Role {
379 pub name: String,
380 pub grants: Vec<Grant>,
381}
382
383#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
385pub struct RoleBinding {
386 pub user_id: u32,
387 pub roles: Vec<String>,
388}
389
390pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
397 crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
398}
399
400pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
404 let pair = match code {
405 AGDX_QUERY_CODE | AGDX_QUERY_PAGE_CODE | AGDX_QUERY_STATUS_CODE => {
406 (Feature::Query, Action::Read)
407 }
408 AGDX_QUERY_CANCEL_CODE => (Feature::Query, Action::Delete),
409 AGDX_DESTINATION_GET_CODE | AGDX_DESTINATION_LIST_CODE | AGDX_QUERY_ROUTE_LIST_CODE => {
410 (Feature::Destination, Action::Read)
411 }
412 AGDX_GET_PROJECTION_CODE
413 | AGDX_LIST_PROJECTIONS_CODE
414 | AGDX_GET_SCHEMA_CODE
415 | AGDX_LIST_SCHEMAS_CODE
416 | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
417 AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
418 AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
419 (Feature::Kv, Action::Read)
420 }
421 AGDX_KV_SET_CODE
426 | AGDX_KV_CAS_CODE
427 | AGDX_KV_CAS_FENCED_CODE
428 | AGDX_KV_PATCH_CODE
429 | AGDX_KV_EXPIRE_CODE
430 | AGDX_KV_COPY_CODE
431 | AGDX_KV_MOVE_CODE => (Feature::Kv, Action::Write),
432 AGDX_KV_LEASE_CODE | AGDX_KV_LEASE_RENEW_CODE | AGDX_KV_RELEASE_CODE => {
438 (Feature::KvLease, Action::Admin)
439 }
440 AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
441 AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
442 AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
443 AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
444 AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
445 AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
446 AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
447 AGDX_SESSION_GET_CODE
448 | AGDX_SESSION_LIST_CODE
449 | AGDX_SESSION_EVENTS_CODE
450 | AGDX_SESSION_STATE_CODE
451 | AGDX_SESSION_LINKS_CODE
452 | AGDX_SESSION_SOURCES_CODE
453 | AGDX_SESSION_CHANGES_CODE => (Feature::Session, Action::Read),
454 AGDX_GET_FILTER_CODE
455 | AGDX_LIST_FILTERS_CODE
456 | AGDX_LIST_FILTER_REVISIONS_CODE
457 | AGDX_GET_FILTER_BINDING_CODE
458 | AGDX_LIST_FILTER_BINDINGS_CODE
459 | AGDX_FILTER_OPERATION_CODE => (Feature::Filter, Action::Read),
460 _ => return None,
461 };
462 Some(pair)
463}
464
465pub const ACTION_COUNT: usize = 5;
472
473pub fn action_index(feature: Feature, action: Action) -> usize {
478 feature as usize * ACTION_COUNT + action as usize
479}
480
481const _: () = {
489 assert!(
490 <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
491 "ACTION_COUNT must equal the number of Action variants"
492 );
493 assert!(
494 <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 128,
495 "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 128 bits"
496 );
497};
498
499pub fn grants_allow(
503 grants: &[Grant],
504 feature: Feature,
505 action: Action,
506 resource: Option<&str>,
507) -> bool {
508 let mut allowed = false;
509 for grant in grants {
510 if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
511 match grant.effect {
512 Effect::Deny => return false,
513 Effect::Allow => allowed = true,
514 }
515 }
516 }
517 allowed
518}
519
520pub fn delegated_allow(
524 agent: &[Grant],
525 user: &[Grant],
526 feature: Feature,
527 action: Action,
528 resource: Option<&str>,
529) -> bool {
530 grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
531}
532
533#[derive(Clone, Debug, Serialize, Deserialize)]
535pub struct WhoamiReq {
536 pub v: u32,
537}
538
539#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
541pub struct WhoamiReply {
542 pub v: u32,
543 pub roles: Vec<String>,
544 pub grants: Vec<Grant>,
545}
546
547#[derive(Clone, Debug, Serialize, Deserialize)]
550pub struct ListRolesReq {
551 pub v: u32,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub name_prefix: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub search: Option<String>,
556}
557
558#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
560pub struct ListRolesReply {
561 pub v: u32,
562 pub roles: Vec<Role>,
563}
564
565#[derive(Clone, Debug, Serialize, Deserialize)]
567pub struct GetRoleReq {
568 pub v: u32,
569 pub name: String,
570}
571
572#[derive(Clone, Debug, Serialize, Deserialize)]
574pub struct GetBindingsReq {
575 pub v: u32,
576 pub user_id: u32,
577}
578
579#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
581pub struct BindingsReply {
582 pub v: u32,
583 pub roles: Vec<String>,
584}
585
586#[derive(Clone, Debug, Serialize, Deserialize)]
588pub struct DefineRoleReq {
589 pub v: u32,
590 pub role: Role,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub mutation_id: Option<String>,
593}
594
595#[derive(Clone, Debug, Serialize, Deserialize)]
597pub struct DeleteRoleReq {
598 pub v: u32,
599 pub name: String,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub mutation_id: Option<String>,
602}
603
604#[derive(Clone, Debug, Serialize, Deserialize)]
606pub struct BindRolesReq {
607 pub v: u32,
608 pub user_id: u32,
609 pub roles: Vec<String>,
610 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub expect_revision: Option<u64>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub mutation_id: Option<String>,
619}
620
621#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(rename_all = "snake_case")]
624pub enum AuthzSubject {
625 Role(String),
627 Binding { user_id: u32 },
629 All,
631 Stream(String),
634}
635
636#[derive(Clone, Debug, Serialize, Deserialize)]
639pub struct AuthzHistoryReq {
640 pub v: u32,
641 pub subject: AuthzSubject,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub after_revision: Option<u64>,
644 pub limit: u32,
645}
646
647#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(rename_all = "snake_case")]
650pub enum AuthzEventKind {
651 RoleDefined(String),
653 RoleDeleted(String),
655 RolesBound { user_id: u32, roles: Vec<String> },
657 TenancySet(Tenancy),
659}
660
661#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
663pub struct AuthzEvent {
664 pub revision: u64,
665 pub actor: String,
666 pub at_micros: u64,
667 pub op: AuthzEventKind,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub stream: Option<String>,
672}
673
674#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
676pub struct AuthzHistoryReply {
677 pub v: u32,
678 pub events: Vec<AuthzEvent>,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub next_after_revision: Option<u64>,
681}
682
683#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
685#[non_exhaustive]
686pub enum AuthzReply {
687 Ok,
689 Whoami(WhoamiReply),
691 Roles(ListRolesReply),
693 Role(Option<Role>),
695 Bindings(BindingsReply),
697 History(AuthzHistoryReply),
699 Err(AuthzError),
700}
701
702#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
704#[non_exhaustive]
705pub enum AuthzError {
706 #[error("authz not supported: {0}")]
707 Unsupported(String),
708 #[error("unauthorized")]
709 Unauthorized,
710 #[error("unknown role: {0}")]
711 UnknownRole(String),
712 #[error("invalid role name: {0}")]
714 InvalidName(String),
715 #[error("revision conflict: current is {current_revision}")]
719 Conflict { current_revision: u64 },
720 #[error("unsupported authz op version (expected {expected}, got {got})")]
721 Version { expected: u32, got: u32 },
722 #[error("refused by stream tenancy: {0}")]
726 TenancyViolation(String),
727}
728
729#[cfg(all(test, feature = "cbor"))]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn given_role_grants_when_attributed_then_should_name_one_stream_or_none() {
735 let allow = |kind: ResourceKind, value: &str| Grant {
736 effect: Effect::Allow,
737 feature: Feature::Kv,
738 action: Action::Read,
739 resource: ResourcePattern {
740 kind,
741 value: value.to_owned(),
742 },
743 };
744 assert_eq!(
745 grants_stream(&[
746 allow(ResourceKind::Prefix, "stream:acme/"),
747 allow(ResourceKind::Literal, "stream:acme/cart"),
748 ]),
749 Some("acme".to_owned())
750 );
751 assert_eq!(
752 grants_stream(&[
753 allow(ResourceKind::Prefix, "stream:acme/"),
754 allow(ResourceKind::Literal, "stream:other/cart"),
755 ]),
756 None
757 );
758 assert_eq!(grants_stream(&[allow(ResourceKind::All, "")]), None);
759 assert_eq!(
760 grants_stream(&[allow(ResourceKind::Prefix, "stream:acme")]),
761 None
762 );
763 assert_eq!(grants_stream(&[]), None);
764 }
765
766 #[test]
767 fn given_a_tenancy_event_when_round_tripped_then_should_keep_it_and_spanning_should_follow_the_pattern()
768 {
769 let event = AuthzEventKind::TenancySet(Tenancy::Stream);
770 let bytes = crate::framing::encode_named(&event).expect("encodes");
771 assert_eq!(
772 crate::framing::decode_named::<AuthzEventKind>(&bytes).expect("decodes"),
773 event
774 );
775 let allow = |resource| Grant {
776 effect: Effect::Allow,
777 feature: Feature::Kv,
778 action: Action::Read,
779 resource,
780 };
781 assert!(grant_spans_streams(&allow(ResourcePattern::all())));
782 assert!(grant_spans_streams(&allow(ResourcePattern::prefix(
783 "orders"
784 ))));
785 assert!(!grant_spans_streams(&allow(ResourcePattern::prefix(
786 "stream:a/"
787 ))));
788 assert!(!grant_spans_streams(&allow(ResourcePattern::literal("x"))));
789 assert!(Tenancy::default().is_deployment());
790 }
791 use crate::framing::{decode_named, encode_named};
792
793 #[test]
794 fn given_a_tenancy_violation_when_round_tripped_then_should_keep_the_reason() {
795 let reply = AuthzReply::Err(AuthzError::TenancyViolation("prefix acme".to_owned()));
796 let back: AuthzReply =
797 decode_named(&encode_named(&reply).expect("reply serializes")).expect("deserializes");
798 assert!(matches!(
799 back,
800 AuthzReply::Err(AuthzError::TenancyViolation(reason)) if reason == "prefix acme"
801 ));
802 }
803
804 #[test]
805 fn given_a_local_name_when_scoped_then_should_prefix_the_stream() {
806 assert_eq!(
807 scoped_resource("acme", "agent.keys"),
808 "stream:acme/agent.keys"
809 );
810 assert_eq!(scoped_resource("acme", "a/b"), "stream:acme/a/b");
811 }
812
813 #[test]
814 fn given_a_scoped_name_when_scoped_again_then_should_return_it_unchanged() {
815 let scoped = scoped_resource("acme", "sessions");
816 assert_eq!(scoped_resource("acme", &scoped), scoped);
817 assert_eq!(
818 scoped_resource("other", "stream:acme/sessions"),
819 "stream:acme/sessions"
820 );
821 }
822
823 #[test]
824 fn given_scoped_names_when_split_then_should_return_stream_and_local_part() {
825 assert_eq!(
826 split_scoped_resource("stream:acme/agent.keys"),
827 Some(("acme", "agent.keys"))
828 );
829 assert_eq!(
830 split_scoped_resource("stream:acme/a/b"),
831 Some(("acme", "a/b"))
832 );
833 assert_eq!(
834 split_scoped_resource(&scoped_resource("acme", "graph")),
835 Some(("acme", "graph"))
836 );
837 }
838
839 #[test]
840 fn given_unscoped_names_when_split_then_should_return_none() {
841 assert_eq!(split_scoped_resource("agent.keys"), None);
842 assert_eq!(split_scoped_resource("stream:acme"), None);
843 assert_eq!(split_scoped_resource("stream:/local"), None);
844 assert_eq!(split_scoped_resource("stream:acme/"), None);
845 assert_eq!(split_scoped_resource("streams:acme/local"), None);
846 }
847
848 #[test]
849 fn given_a_role_when_round_tripped_then_should_preserve_grants() {
850 let role = Role {
851 name: "kv-reader".to_string(),
852 grants: vec![
853 Grant {
854 effect: Effect::Allow,
855 feature: Feature::Kv,
856 action: Action::Read,
857 resource: ResourcePattern::prefix("agent-abc/"),
858 },
859 Grant {
860 effect: Effect::Deny,
861 feature: Feature::Kv,
862 action: Action::Read,
863 resource: ResourcePattern::literal("agent-abc/secret"),
864 },
865 ],
866 };
867 let bytes = encode_named(&role).expect("role serializes");
868 let back: Role = decode_named(&bytes).expect("role deserializes");
869 assert_eq!(back, role);
870 }
871
872 #[test]
873 fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
874 assert!(validate_role_name("kv-reader").is_ok());
875 assert!(validate_role_name("ops.admin_2").is_ok());
876 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
877 assert!(validate_role_name("").is_err(), "empty");
878 assert!(validate_role_name("bad name").is_err(), "space");
879 assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
880 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
881 }
882
883 #[test]
884 fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
885 assert!(ResourcePattern::all().matches(Some("anything")));
886 assert!(ResourcePattern::all().matches(None));
887 assert!(ResourcePattern::literal("ns").matches(Some("ns")));
888 assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
889 assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
890 assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
891 assert!(!ResourcePattern::literal("ns").matches(None));
894 assert!(!ResourcePattern::prefix("agent-").matches(None));
895 }
896
897 #[test]
898 fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
899 let allow = |feature, action, resource| Grant {
900 effect: Effect::Allow,
901 feature,
902 action,
903 resource,
904 };
905 let agent = vec![
908 allow(Feature::Kv, Action::Read, ResourcePattern::all()),
909 allow(Feature::Kv, Action::Write, ResourcePattern::all()),
910 ];
911 let user = vec![allow(
912 Feature::Kv,
913 Action::Read,
914 ResourcePattern::prefix("shared/"),
915 )];
916 assert!(delegated_allow(
917 &agent,
918 &user,
919 Feature::Kv,
920 Action::Read,
921 Some("shared/x")
922 ));
923 assert!(!delegated_allow(
925 &agent,
926 &user,
927 Feature::Kv,
928 Action::Read,
929 Some("private/x")
930 ));
931 assert!(!delegated_allow(
933 &agent,
934 &user,
935 Feature::Kv,
936 Action::Write,
937 Some("shared/x")
938 ));
939 assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
941 }
942
943 #[test]
944 fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
945 assert_eq!(
946 feature_action(AGDX_KV_GET_CODE),
947 Some((Feature::Kv, Action::Read))
948 );
949 assert_eq!(
950 feature_action(AGDX_KV_SET_CODE),
951 Some((Feature::Kv, Action::Write))
952 );
953 assert_eq!(
954 feature_action(AGDX_KV_DELETE_CODE),
955 Some((Feature::Kv, Action::Delete))
956 );
957 assert_eq!(
960 feature_action(AGDX_KV_LEASE_CODE),
961 Some((Feature::KvLease, Action::Admin))
962 );
963 assert_eq!(
964 feature_action(AGDX_KV_LEASE_RENEW_CODE),
965 Some((Feature::KvLease, Action::Admin))
966 );
967 assert_eq!(
968 feature_action(AGDX_KV_RELEASE_CODE),
969 Some((Feature::KvLease, Action::Admin))
970 );
971 assert_eq!(
972 feature_action(AGDX_KV_CAS_FENCED_CODE),
973 Some((Feature::Kv, Action::Write))
974 );
975 assert_eq!(
976 feature_action(AGDX_REGISTER_SCHEMA_CODE),
977 Some((Feature::Projection, Action::Admin))
978 );
979 assert_eq!(
980 feature_action(AGDX_QUERY_CODE),
981 Some((Feature::Query, Action::Read))
982 );
983 assert_eq!(
984 feature_action(AGDX_GRAPH_UPSERT_CODE),
985 Some((Feature::Graph, Action::Write))
986 );
987 assert_eq!(
988 feature_action(AGDX_DESTINATION_GET_CODE),
989 Some((Feature::Destination, Action::Read))
990 );
991 assert_eq!(feature_action(AGDX_CHECKPOINT_CODE), None);
992 assert_eq!(feature_action(AGDX_DESTINATION_HTTP_CODE), None);
993 assert_eq!(feature_action(AGDX_HELLO_CODE), None);
995 assert_eq!(feature_action(AGDX_BATCH_CODE), None);
996 assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
997 assert_eq!(feature_action(AGDX_CONTROL_PUBLISH_CODE), None);
998 }
999
1000 #[test]
1001 fn given_feature_action_pairs_when_indexed_then_should_fit_a_u128_mask() {
1002 use strum::VariantArray;
1003 let mut seen = std::collections::HashSet::new();
1004 for &feature in Feature::VARIANTS {
1005 for &action in Action::VARIANTS {
1006 let index = action_index(feature, action);
1007 assert!(index < 128, "index {index} must fit a u128 mask");
1008 assert!(seen.insert(index), "index {index} collided");
1009 }
1010 }
1011 assert_eq!(action_index(Feature::Destination, Action::Read), 40);
1012 assert_eq!(action_index(Feature::Checkpoint, Action::Admin), 48);
1013 assert_eq!(action_index(Feature::Session, Action::Read), 70);
1014 }
1015
1016 #[test]
1017 fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
1018 let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
1021 let grant: Grant =
1022 serde_json::from_str(json).expect("an unknown feature/action still decodes");
1023 assert_eq!(grant.feature, Feature::Unrecognized);
1024 assert_eq!(grant.action, Action::Unrecognized);
1025 assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
1029 assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
1033 assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
1034 assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
1035 assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
1036 }
1037
1038 #[test]
1039 fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
1040 let reply = AuthzReply::Whoami(WhoamiReply {
1041 v: AUTHZ_OP_VERSION,
1042 roles: vec!["admin".to_string()],
1043 grants: vec![Grant {
1044 effect: Effect::Allow,
1045 feature: Feature::Kv,
1046 action: Action::Write,
1047 resource: ResourcePattern::all(),
1048 }],
1049 });
1050 let bytes = encode_named(&reply).expect("reply serializes");
1051 let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
1052 assert_eq!(back, reply);
1053 }
1054}