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 #[serde(other)]
81 Unrecognized,
82}
83
84pub const SUPERVISOR_ASSERTION_VERSION: u32 = 1;
85pub const SUPERVISOR_ASSERTION_DOMAIN: &[u8] = b"agdx.supervisor-actor.v1";
86pub const MAX_SUPERVISOR_ASSERTION_TTL_MICROS: u64 = 5 * 60 * 1_000_000;
87pub const SUPERVISOR_ASSERTION_KEY_ID_BYTES: usize = 8;
88pub const SUPERVISOR_ASSERTION_SIGNATURE_BYTES: usize = 64;
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct SupervisorActorClaims {
93 pub v: u32,
94 pub request_id: CheckpointRequestId,
95 pub deployment_id: u32,
96 pub cloud_user_id: u32,
97 pub action: SupervisorAssertionAction,
98 pub destination_id: DestinationId,
99 pub destination_generation: u64,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub expected_revision: Option<u64>,
102 pub issued_at_micros: u64,
103 pub expires_at_micros: u64,
104}
105
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub struct SupervisorActorAssertion {
109 pub claims: SupervisorActorClaims,
110 #[serde(with = "crate::encoding::bin_bytes")]
111 pub key_id: Vec<u8>,
112 #[serde(with = "crate::encoding::bin_bytes")]
113 pub signature: Vec<u8>,
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118#[non_exhaustive]
119pub enum SupervisorAssertionAction {
120 AcceptRetentionGap,
121 SupersedeGeneration,
122 RecordRepair,
123}
124
125impl SupervisorActorAssertion {
126 pub fn validate(&self) -> Result<(), InvalidError> {
127 let claims = &self.claims;
128 if claims.v != SUPERVISOR_ASSERTION_VERSION {
129 return Err(InvalidError::new(format!(
130 "supervisor assertion version must be {SUPERVISOR_ASSERTION_VERSION}"
131 )));
132 }
133 if claims.request_id.as_u128() == 0
134 || claims.deployment_id == 0
135 || claims.cloud_user_id == 0
136 || claims.destination_id.as_u128() == 0
137 || claims.destination_generation == 0
138 {
139 return Err(InvalidError::new(
140 "supervisor assertion identities must be nonzero",
141 ));
142 }
143 if claims.issued_at_micros == 0
144 || claims.expires_at_micros <= claims.issued_at_micros
145 || claims.expires_at_micros - claims.issued_at_micros
146 > MAX_SUPERVISOR_ASSERTION_TTL_MICROS
147 {
148 return Err(InvalidError::new(
149 "supervisor assertion validity window is invalid",
150 ));
151 }
152 if self.key_id.len() != SUPERVISOR_ASSERTION_KEY_ID_BYTES
153 || self.signature.len() != SUPERVISOR_ASSERTION_SIGNATURE_BYTES
154 {
155 return Err(InvalidError::new(
156 "supervisor assertion key id or signature length is invalid",
157 ));
158 }
159 Ok(())
160 }
161}
162
163#[derive(
165 Clone,
166 Copy,
167 Debug,
168 PartialEq,
169 Eq,
170 Hash,
171 Serialize,
172 Deserialize,
173 strum::Display,
174 strum::EnumString,
175 strum::VariantArray,
176)]
177#[strum(serialize_all = "snake_case")]
178#[serde(rename_all = "snake_case")]
179#[non_exhaustive]
180pub enum Action {
181 Read,
182 Write,
183 Delete,
184 Admin,
185 #[serde(other)]
190 Unrecognized,
191}
192
193#[derive(
195 Clone,
196 Copy,
197 Debug,
198 Default,
199 PartialEq,
200 Eq,
201 Serialize,
202 Deserialize,
203 strum::Display,
204 strum::EnumString,
205 strum::VariantArray,
206)]
207#[strum(serialize_all = "snake_case")]
208#[serde(rename_all = "snake_case")]
209#[non_exhaustive]
210pub enum ResourceKind {
211 #[default]
213 All,
214 Literal,
216 Prefix,
218}
219
220#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
222pub struct ResourcePattern {
223 #[serde(default)]
224 pub kind: ResourceKind,
225 #[serde(default, skip_serializing_if = "String::is_empty")]
226 pub value: String,
227}
228
229impl ResourcePattern {
230 pub fn all() -> Self {
232 Self::default()
233 }
234
235 pub fn literal(value: impl Into<String>) -> Self {
237 Self {
238 kind: ResourceKind::Literal,
239 value: value.into(),
240 }
241 }
242
243 pub fn prefix(value: impl Into<String>) -> Self {
245 Self {
246 kind: ResourceKind::Prefix,
247 value: value.into(),
248 }
249 }
250
251 pub fn matches(&self, resource: Option<&str>) -> bool {
254 match (self.kind, resource) {
255 (ResourceKind::All, _) => true,
256 (ResourceKind::Literal, Some(r)) => r == self.value,
257 (ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
258 (_, None) => false,
259 }
260 }
261}
262
263#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
266pub struct Grant {
267 pub effect: Effect,
268 pub feature: Feature,
269 pub action: Action,
270 #[serde(default)]
271 pub resource: ResourcePattern,
272}
273
274#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277pub struct Role {
278 pub name: String,
279 pub grants: Vec<Grant>,
280}
281
282#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
284pub struct RoleBinding {
285 pub user_id: u32,
286 pub roles: Vec<String>,
287}
288
289pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
296 crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
297}
298
299pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
303 let pair = match code {
304 AGDX_QUERY_CODE | AGDX_QUERY_PAGE_CODE | AGDX_QUERY_STATUS_CODE => {
305 (Feature::Query, Action::Read)
306 }
307 AGDX_QUERY_CANCEL_CODE => (Feature::Query, Action::Delete),
308 AGDX_DESTINATION_GET_CODE | AGDX_DESTINATION_LIST_CODE | AGDX_QUERY_ROUTE_LIST_CODE => {
309 (Feature::Destination, Action::Read)
310 }
311 AGDX_CHECKPOINT_CODE => (Feature::Checkpoint, Action::Write),
312 AGDX_GET_PROJECTION_CODE
313 | AGDX_LIST_PROJECTIONS_CODE
314 | AGDX_GET_SCHEMA_CODE
315 | AGDX_LIST_SCHEMAS_CODE
316 | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
317 AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
318 AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
319 (Feature::Kv, Action::Read)
320 }
321 AGDX_KV_SET_CODE
326 | AGDX_KV_CAS_CODE
327 | AGDX_KV_CAS_FENCED_CODE
328 | AGDX_KV_PATCH_CODE
329 | AGDX_KV_EXPIRE_CODE
330 | AGDX_KV_COPY_CODE
331 | AGDX_KV_MOVE_CODE => (Feature::Kv, Action::Write),
332 AGDX_KV_LEASE_CODE | AGDX_KV_LEASE_RENEW_CODE | AGDX_KV_RELEASE_CODE => {
338 (Feature::KvLease, Action::Admin)
339 }
340 AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
341 AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
342 AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
343 AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
344 AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
345 AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
346 AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
347 AGDX_AGENT_STATUS_CODE | AGDX_AGENT_LIST_CODE => (Feature::Agent, Action::Read),
348 AGDX_AGENT_SUBMIT_CODE => (Feature::Agent, Action::Write),
349 AGDX_AGENT_CANCEL_CODE => (Feature::Agent, Action::Delete),
350 _ => return None,
351 };
352 Some(pair)
353}
354
355pub const ACTION_COUNT: usize = 5;
362
363pub fn action_index(feature: Feature, action: Action) -> usize {
368 feature as usize * ACTION_COUNT + action as usize
369}
370
371const _: () = {
379 assert!(
380 <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
381 "ACTION_COUNT must equal the number of Action variants"
382 );
383 assert!(
384 <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 128,
385 "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 128 bits"
386 );
387};
388
389pub fn grants_allow(
393 grants: &[Grant],
394 feature: Feature,
395 action: Action,
396 resource: Option<&str>,
397) -> bool {
398 let mut allowed = false;
399 for grant in grants {
400 if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
401 match grant.effect {
402 Effect::Deny => return false,
403 Effect::Allow => allowed = true,
404 }
405 }
406 }
407 allowed
408}
409
410pub fn delegated_allow(
414 agent: &[Grant],
415 user: &[Grant],
416 feature: Feature,
417 action: Action,
418 resource: Option<&str>,
419) -> bool {
420 grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
421}
422
423#[derive(Clone, Debug, Serialize, Deserialize)]
425pub struct WhoamiReq {
426 pub v: u32,
427}
428
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
431pub struct WhoamiReply {
432 pub v: u32,
433 pub roles: Vec<String>,
434 pub grants: Vec<Grant>,
435}
436
437#[derive(Clone, Debug, Serialize, Deserialize)]
440pub struct ListRolesReq {
441 pub v: u32,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub name_prefix: Option<String>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub search: Option<String>,
446}
447
448#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
450pub struct ListRolesReply {
451 pub v: u32,
452 pub roles: Vec<Role>,
453}
454
455#[derive(Clone, Debug, Serialize, Deserialize)]
457pub struct GetRoleReq {
458 pub v: u32,
459 pub name: String,
460}
461
462#[derive(Clone, Debug, Serialize, Deserialize)]
464pub struct GetBindingsReq {
465 pub v: u32,
466 pub user_id: u32,
467}
468
469#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
471pub struct BindingsReply {
472 pub v: u32,
473 pub roles: Vec<String>,
474}
475
476#[derive(Clone, Debug, Serialize, Deserialize)]
478pub struct DefineRoleReq {
479 pub v: u32,
480 pub role: Role,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub mutation_id: Option<String>,
483}
484
485#[derive(Clone, Debug, Serialize, Deserialize)]
487pub struct DeleteRoleReq {
488 pub v: u32,
489 pub name: String,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub mutation_id: Option<String>,
492}
493
494#[derive(Clone, Debug, Serialize, Deserialize)]
496pub struct BindRolesReq {
497 pub v: u32,
498 pub user_id: u32,
499 pub roles: Vec<String>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub expect_revision: Option<u64>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub mutation_id: Option<String>,
509}
510
511#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
513#[serde(rename_all = "snake_case")]
514pub enum AuthzSubject {
515 Role(String),
517 Binding { user_id: u32 },
519 All,
521}
522
523#[derive(Clone, Debug, Serialize, Deserialize)]
526pub struct AuthzHistoryReq {
527 pub v: u32,
528 pub subject: AuthzSubject,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub after_revision: Option<u64>,
531 pub limit: u32,
532}
533
534#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
536#[serde(rename_all = "snake_case")]
537pub enum AuthzEventKind {
538 RoleDefined(String),
540 RoleDeleted(String),
542 RolesBound { user_id: u32, roles: Vec<String> },
544}
545
546#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
548pub struct AuthzEvent {
549 pub revision: u64,
550 pub actor: String,
551 pub at_micros: u64,
552 pub op: AuthzEventKind,
553}
554
555#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
557pub struct AuthzHistoryReply {
558 pub v: u32,
559 pub events: Vec<AuthzEvent>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
561 pub next_after_revision: Option<u64>,
562}
563
564#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
566#[non_exhaustive]
567pub enum AuthzReply {
568 Ok,
570 Whoami(WhoamiReply),
572 Roles(ListRolesReply),
574 Role(Option<Role>),
576 Bindings(BindingsReply),
578 History(AuthzHistoryReply),
580 Err(AuthzError),
581}
582
583#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
585#[non_exhaustive]
586pub enum AuthzError {
587 #[error("authz not supported: {0}")]
588 Unsupported(String),
589 #[error("unauthorized")]
590 Unauthorized,
591 #[error("unknown role: {0}")]
592 UnknownRole(String),
593 #[error("invalid role name: {0}")]
595 InvalidName(String),
596 #[error("revision conflict: current is {current_revision}")]
600 Conflict { current_revision: u64 },
601 #[error("unsupported authz op version (expected {expected}, got {got})")]
602 Version { expected: u32, got: u32 },
603}
604
605#[cfg(all(test, feature = "cbor"))]
606mod tests {
607 use super::*;
608 use crate::framing::{decode_named, encode_named};
609
610 #[test]
611 fn given_a_role_when_round_tripped_then_should_preserve_grants() {
612 let role = Role {
613 name: "kv-reader".to_string(),
614 grants: vec![
615 Grant {
616 effect: Effect::Allow,
617 feature: Feature::Kv,
618 action: Action::Read,
619 resource: ResourcePattern::prefix("agent-abc/"),
620 },
621 Grant {
622 effect: Effect::Deny,
623 feature: Feature::Kv,
624 action: Action::Read,
625 resource: ResourcePattern::literal("agent-abc/secret"),
626 },
627 ],
628 };
629 let bytes = encode_named(&role).expect("role serializes");
630 let back: Role = decode_named(&bytes).expect("role deserializes");
631 assert_eq!(back, role);
632 }
633
634 #[test]
635 fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
636 assert!(validate_role_name("kv-reader").is_ok());
637 assert!(validate_role_name("ops.admin_2").is_ok());
638 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
639 assert!(validate_role_name("").is_err(), "empty");
640 assert!(validate_role_name("bad name").is_err(), "space");
641 assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
642 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
643 }
644
645 #[test]
646 fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
647 assert!(ResourcePattern::all().matches(Some("anything")));
648 assert!(ResourcePattern::all().matches(None));
649 assert!(ResourcePattern::literal("ns").matches(Some("ns")));
650 assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
651 assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
652 assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
653 assert!(!ResourcePattern::literal("ns").matches(None));
656 assert!(!ResourcePattern::prefix("agent-").matches(None));
657 }
658
659 #[test]
660 fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
661 let allow = |feature, action, resource| Grant {
662 effect: Effect::Allow,
663 feature,
664 action,
665 resource,
666 };
667 let agent = vec![
670 allow(Feature::Kv, Action::Read, ResourcePattern::all()),
671 allow(Feature::Kv, Action::Write, ResourcePattern::all()),
672 ];
673 let user = vec![allow(
674 Feature::Kv,
675 Action::Read,
676 ResourcePattern::prefix("shared/"),
677 )];
678 assert!(delegated_allow(
679 &agent,
680 &user,
681 Feature::Kv,
682 Action::Read,
683 Some("shared/x")
684 ));
685 assert!(!delegated_allow(
687 &agent,
688 &user,
689 Feature::Kv,
690 Action::Read,
691 Some("private/x")
692 ));
693 assert!(!delegated_allow(
695 &agent,
696 &user,
697 Feature::Kv,
698 Action::Write,
699 Some("shared/x")
700 ));
701 assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
703 }
704
705 #[test]
706 fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
707 assert_eq!(
708 feature_action(AGDX_KV_GET_CODE),
709 Some((Feature::Kv, Action::Read))
710 );
711 assert_eq!(
712 feature_action(AGDX_KV_SET_CODE),
713 Some((Feature::Kv, Action::Write))
714 );
715 assert_eq!(
716 feature_action(AGDX_KV_DELETE_CODE),
717 Some((Feature::Kv, Action::Delete))
718 );
719 assert_eq!(
722 feature_action(AGDX_KV_LEASE_CODE),
723 Some((Feature::KvLease, Action::Admin))
724 );
725 assert_eq!(
726 feature_action(AGDX_KV_LEASE_RENEW_CODE),
727 Some((Feature::KvLease, Action::Admin))
728 );
729 assert_eq!(
730 feature_action(AGDX_KV_RELEASE_CODE),
731 Some((Feature::KvLease, Action::Admin))
732 );
733 assert_eq!(
734 feature_action(AGDX_KV_CAS_FENCED_CODE),
735 Some((Feature::Kv, Action::Write))
736 );
737 assert_eq!(
738 feature_action(AGDX_REGISTER_SCHEMA_CODE),
739 Some((Feature::Projection, Action::Admin))
740 );
741 assert_eq!(
742 feature_action(AGDX_QUERY_CODE),
743 Some((Feature::Query, Action::Read))
744 );
745 assert_eq!(
746 feature_action(AGDX_GRAPH_UPSERT_CODE),
747 Some((Feature::Graph, Action::Write))
748 );
749 assert_eq!(feature_action(AGDX_HELLO_CODE), None);
751 assert_eq!(feature_action(AGDX_BATCH_CODE), None);
752 assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
753 }
754
755 #[test]
756 fn given_feature_action_pairs_when_indexed_then_should_fit_a_u128_mask() {
757 use strum::VariantArray;
758 let mut seen = std::collections::HashSet::new();
759 for &feature in Feature::VARIANTS {
760 for &action in Action::VARIANTS {
761 let index = action_index(feature, action);
762 assert!(index < 128, "index {index} must fit a u128 mask");
763 assert!(seen.insert(index), "index {index} collided");
764 }
765 }
766 }
767
768 #[test]
769 fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
770 let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
773 let grant: Grant =
774 serde_json::from_str(json).expect("an unknown feature/action still decodes");
775 assert_eq!(grant.feature, Feature::Unrecognized);
776 assert_eq!(grant.action, Action::Unrecognized);
777 assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
781 assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
785 assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
786 assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
787 assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
788 }
789
790 #[test]
791 fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
792 let reply = AuthzReply::Whoami(WhoamiReply {
793 v: AUTHZ_OP_VERSION,
794 roles: vec!["admin".to_string()],
795 grants: vec![Grant {
796 effect: Effect::Allow,
797 feature: Feature::Kv,
798 action: Action::Write,
799 resource: ResourcePattern::all(),
800 }],
801 });
802 let bytes = encode_named(&reply).expect("reply serializes");
803 let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
804 assert_eq!(back, reply);
805 }
806}