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_GET_PROJECTION_CODE
312 | AGDX_LIST_PROJECTIONS_CODE
313 | AGDX_GET_SCHEMA_CODE
314 | AGDX_LIST_SCHEMAS_CODE
315 | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
316 AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
317 AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
318 (Feature::Kv, Action::Read)
319 }
320 AGDX_KV_SET_CODE
325 | AGDX_KV_CAS_CODE
326 | AGDX_KV_CAS_FENCED_CODE
327 | AGDX_KV_PATCH_CODE
328 | AGDX_KV_EXPIRE_CODE
329 | AGDX_KV_COPY_CODE
330 | AGDX_KV_MOVE_CODE => (Feature::Kv, Action::Write),
331 AGDX_KV_LEASE_CODE | AGDX_KV_LEASE_RENEW_CODE | AGDX_KV_RELEASE_CODE => {
337 (Feature::KvLease, Action::Admin)
338 }
339 AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
340 AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
341 AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
342 AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
343 AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
344 AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
345 AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
346 AGDX_AGENT_STATUS_CODE | AGDX_AGENT_LIST_CODE => (Feature::Agent, Action::Read),
347 AGDX_AGENT_SUBMIT_CODE => (Feature::Agent, Action::Write),
348 AGDX_AGENT_CANCEL_CODE => (Feature::Agent, Action::Delete),
349 _ => return None,
350 };
351 Some(pair)
352}
353
354pub const ACTION_COUNT: usize = 5;
361
362pub fn action_index(feature: Feature, action: Action) -> usize {
367 feature as usize * ACTION_COUNT + action as usize
368}
369
370const _: () = {
378 assert!(
379 <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
380 "ACTION_COUNT must equal the number of Action variants"
381 );
382 assert!(
383 <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 128,
384 "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 128 bits"
385 );
386};
387
388pub fn grants_allow(
392 grants: &[Grant],
393 feature: Feature,
394 action: Action,
395 resource: Option<&str>,
396) -> bool {
397 let mut allowed = false;
398 for grant in grants {
399 if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
400 match grant.effect {
401 Effect::Deny => return false,
402 Effect::Allow => allowed = true,
403 }
404 }
405 }
406 allowed
407}
408
409pub fn delegated_allow(
413 agent: &[Grant],
414 user: &[Grant],
415 feature: Feature,
416 action: Action,
417 resource: Option<&str>,
418) -> bool {
419 grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
420}
421
422#[derive(Clone, Debug, Serialize, Deserialize)]
424pub struct WhoamiReq {
425 pub v: u32,
426}
427
428#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430pub struct WhoamiReply {
431 pub v: u32,
432 pub roles: Vec<String>,
433 pub grants: Vec<Grant>,
434}
435
436#[derive(Clone, Debug, Serialize, Deserialize)]
439pub struct ListRolesReq {
440 pub v: u32,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub name_prefix: Option<String>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub search: Option<String>,
445}
446
447#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
449pub struct ListRolesReply {
450 pub v: u32,
451 pub roles: Vec<Role>,
452}
453
454#[derive(Clone, Debug, Serialize, Deserialize)]
456pub struct GetRoleReq {
457 pub v: u32,
458 pub name: String,
459}
460
461#[derive(Clone, Debug, Serialize, Deserialize)]
463pub struct GetBindingsReq {
464 pub v: u32,
465 pub user_id: u32,
466}
467
468#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
470pub struct BindingsReply {
471 pub v: u32,
472 pub roles: Vec<String>,
473}
474
475#[derive(Clone, Debug, Serialize, Deserialize)]
477pub struct DefineRoleReq {
478 pub v: u32,
479 pub role: Role,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub mutation_id: Option<String>,
482}
483
484#[derive(Clone, Debug, Serialize, Deserialize)]
486pub struct DeleteRoleReq {
487 pub v: u32,
488 pub name: String,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub mutation_id: Option<String>,
491}
492
493#[derive(Clone, Debug, Serialize, Deserialize)]
495pub struct BindRolesReq {
496 pub v: u32,
497 pub user_id: u32,
498 pub roles: Vec<String>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub expect_revision: Option<u64>,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub mutation_id: Option<String>,
508}
509
510#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513pub enum AuthzSubject {
514 Role(String),
516 Binding { user_id: u32 },
518 All,
520}
521
522#[derive(Clone, Debug, Serialize, Deserialize)]
525pub struct AuthzHistoryReq {
526 pub v: u32,
527 pub subject: AuthzSubject,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub after_revision: Option<u64>,
530 pub limit: u32,
531}
532
533#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
535#[serde(rename_all = "snake_case")]
536pub enum AuthzEventKind {
537 RoleDefined(String),
539 RoleDeleted(String),
541 RolesBound { user_id: u32, roles: Vec<String> },
543}
544
545#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
547pub struct AuthzEvent {
548 pub revision: u64,
549 pub actor: String,
550 pub at_micros: u64,
551 pub op: AuthzEventKind,
552}
553
554#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
556pub struct AuthzHistoryReply {
557 pub v: u32,
558 pub events: Vec<AuthzEvent>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub next_after_revision: Option<u64>,
561}
562
563#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
565#[non_exhaustive]
566pub enum AuthzReply {
567 Ok,
569 Whoami(WhoamiReply),
571 Roles(ListRolesReply),
573 Role(Option<Role>),
575 Bindings(BindingsReply),
577 History(AuthzHistoryReply),
579 Err(AuthzError),
580}
581
582#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
584#[non_exhaustive]
585pub enum AuthzError {
586 #[error("authz not supported: {0}")]
587 Unsupported(String),
588 #[error("unauthorized")]
589 Unauthorized,
590 #[error("unknown role: {0}")]
591 UnknownRole(String),
592 #[error("invalid role name: {0}")]
594 InvalidName(String),
595 #[error("revision conflict: current is {current_revision}")]
599 Conflict { current_revision: u64 },
600 #[error("unsupported authz op version (expected {expected}, got {got})")]
601 Version { expected: u32, got: u32 },
602}
603
604#[cfg(all(test, feature = "cbor"))]
605mod tests {
606 use super::*;
607 use crate::framing::{decode_named, encode_named};
608
609 #[test]
610 fn given_a_role_when_round_tripped_then_should_preserve_grants() {
611 let role = Role {
612 name: "kv-reader".to_string(),
613 grants: vec![
614 Grant {
615 effect: Effect::Allow,
616 feature: Feature::Kv,
617 action: Action::Read,
618 resource: ResourcePattern::prefix("agent-abc/"),
619 },
620 Grant {
621 effect: Effect::Deny,
622 feature: Feature::Kv,
623 action: Action::Read,
624 resource: ResourcePattern::literal("agent-abc/secret"),
625 },
626 ],
627 };
628 let bytes = encode_named(&role).expect("role serializes");
629 let back: Role = decode_named(&bytes).expect("role deserializes");
630 assert_eq!(back, role);
631 }
632
633 #[test]
634 fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
635 assert!(validate_role_name("kv-reader").is_ok());
636 assert!(validate_role_name("ops.admin_2").is_ok());
637 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
638 assert!(validate_role_name("").is_err(), "empty");
639 assert!(validate_role_name("bad name").is_err(), "space");
640 assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
641 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
642 }
643
644 #[test]
645 fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
646 assert!(ResourcePattern::all().matches(Some("anything")));
647 assert!(ResourcePattern::all().matches(None));
648 assert!(ResourcePattern::literal("ns").matches(Some("ns")));
649 assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
650 assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
651 assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
652 assert!(!ResourcePattern::literal("ns").matches(None));
655 assert!(!ResourcePattern::prefix("agent-").matches(None));
656 }
657
658 #[test]
659 fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
660 let allow = |feature, action, resource| Grant {
661 effect: Effect::Allow,
662 feature,
663 action,
664 resource,
665 };
666 let agent = vec![
669 allow(Feature::Kv, Action::Read, ResourcePattern::all()),
670 allow(Feature::Kv, Action::Write, ResourcePattern::all()),
671 ];
672 let user = vec![allow(
673 Feature::Kv,
674 Action::Read,
675 ResourcePattern::prefix("shared/"),
676 )];
677 assert!(delegated_allow(
678 &agent,
679 &user,
680 Feature::Kv,
681 Action::Read,
682 Some("shared/x")
683 ));
684 assert!(!delegated_allow(
686 &agent,
687 &user,
688 Feature::Kv,
689 Action::Read,
690 Some("private/x")
691 ));
692 assert!(!delegated_allow(
694 &agent,
695 &user,
696 Feature::Kv,
697 Action::Write,
698 Some("shared/x")
699 ));
700 assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
702 }
703
704 #[test]
705 fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
706 assert_eq!(
707 feature_action(AGDX_KV_GET_CODE),
708 Some((Feature::Kv, Action::Read))
709 );
710 assert_eq!(
711 feature_action(AGDX_KV_SET_CODE),
712 Some((Feature::Kv, Action::Write))
713 );
714 assert_eq!(
715 feature_action(AGDX_KV_DELETE_CODE),
716 Some((Feature::Kv, Action::Delete))
717 );
718 assert_eq!(
721 feature_action(AGDX_KV_LEASE_CODE),
722 Some((Feature::KvLease, Action::Admin))
723 );
724 assert_eq!(
725 feature_action(AGDX_KV_LEASE_RENEW_CODE),
726 Some((Feature::KvLease, Action::Admin))
727 );
728 assert_eq!(
729 feature_action(AGDX_KV_RELEASE_CODE),
730 Some((Feature::KvLease, Action::Admin))
731 );
732 assert_eq!(
733 feature_action(AGDX_KV_CAS_FENCED_CODE),
734 Some((Feature::Kv, Action::Write))
735 );
736 assert_eq!(
737 feature_action(AGDX_REGISTER_SCHEMA_CODE),
738 Some((Feature::Projection, Action::Admin))
739 );
740 assert_eq!(
741 feature_action(AGDX_QUERY_CODE),
742 Some((Feature::Query, Action::Read))
743 );
744 assert_eq!(
745 feature_action(AGDX_GRAPH_UPSERT_CODE),
746 Some((Feature::Graph, Action::Write))
747 );
748 assert_eq!(
749 feature_action(AGDX_DESTINATION_GET_CODE),
750 Some((Feature::Destination, Action::Read))
751 );
752 assert_eq!(feature_action(AGDX_CHECKPOINT_CODE), None);
753 assert_eq!(feature_action(AGDX_DESTINATION_HTTP_CODE), None);
754 assert_eq!(feature_action(AGDX_HELLO_CODE), None);
756 assert_eq!(feature_action(AGDX_BATCH_CODE), None);
757 assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
758 assert_eq!(feature_action(AGDX_CONTROL_PUBLISH_CODE), None);
759 }
760
761 #[test]
762 fn given_feature_action_pairs_when_indexed_then_should_fit_a_u128_mask() {
763 use strum::VariantArray;
764 let mut seen = std::collections::HashSet::new();
765 for &feature in Feature::VARIANTS {
766 for &action in Action::VARIANTS {
767 let index = action_index(feature, action);
768 assert!(index < 128, "index {index} must fit a u128 mask");
769 assert!(seen.insert(index), "index {index} collided");
770 }
771 }
772 assert_eq!(action_index(Feature::Destination, Action::Read), 40);
773 assert_eq!(action_index(Feature::Checkpoint, Action::Admin), 48);
774 }
775
776 #[test]
777 fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
778 let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
781 let grant: Grant =
782 serde_json::from_str(json).expect("an unknown feature/action still decodes");
783 assert_eq!(grant.feature, Feature::Unrecognized);
784 assert_eq!(grant.action, Action::Unrecognized);
785 assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
789 assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
793 assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
794 assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
795 assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
796 }
797
798 #[test]
799 fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
800 let reply = AuthzReply::Whoami(WhoamiReply {
801 v: AUTHZ_OP_VERSION,
802 roles: vec!["admin".to_string()],
803 grants: vec![Grant {
804 effect: Effect::Allow,
805 feature: Feature::Kv,
806 action: Action::Write,
807 resource: ResourcePattern::all(),
808 }],
809 });
810 let bytes = encode_named(&reply).expect("reply serializes");
811 let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
812 assert_eq!(back, reply);
813 }
814}