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 #[serde(other)]
70 Unrecognized,
71}
72
73pub const SUPERVISOR_ASSERTION_VERSION: u32 = 1;
74pub const SUPERVISOR_ASSERTION_DOMAIN: &[u8] = b"agdx.supervisor-actor.v1";
75pub const MAX_SUPERVISOR_ASSERTION_TTL_MICROS: u64 = 5 * 60 * 1_000_000;
76pub const SUPERVISOR_ASSERTION_KEY_ID_BYTES: usize = 8;
77pub const SUPERVISOR_ASSERTION_SIGNATURE_BYTES: usize = 64;
78
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct SupervisorActorClaims {
82 pub v: u32,
83 pub request_id: CheckpointRequestId,
84 pub deployment_id: u32,
85 pub cloud_user_id: u32,
86 pub action: SupervisorAssertionAction,
87 pub destination_id: DestinationId,
88 pub destination_generation: u64,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub expected_revision: Option<u64>,
91 pub issued_at_micros: u64,
92 pub expires_at_micros: u64,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct SupervisorActorAssertion {
98 pub claims: SupervisorActorClaims,
99 #[serde(with = "crate::encoding::bin_bytes")]
100 pub key_id: Vec<u8>,
101 #[serde(with = "crate::encoding::bin_bytes")]
102 pub signature: Vec<u8>,
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107#[non_exhaustive]
108pub enum SupervisorAssertionAction {
109 AcceptRetentionGap,
110 SupersedeGeneration,
111 RecordRepair,
112}
113
114impl SupervisorActorAssertion {
115 pub fn validate(&self) -> Result<(), InvalidError> {
116 let claims = &self.claims;
117 if claims.v != SUPERVISOR_ASSERTION_VERSION {
118 return Err(InvalidError::new(format!(
119 "supervisor assertion version must be {SUPERVISOR_ASSERTION_VERSION}"
120 )));
121 }
122 if claims.request_id.as_u128() == 0
123 || claims.deployment_id == 0
124 || claims.cloud_user_id == 0
125 || claims.destination_id.as_u128() == 0
126 || claims.destination_generation == 0
127 {
128 return Err(InvalidError::new(
129 "supervisor assertion identities must be nonzero",
130 ));
131 }
132 if claims.issued_at_micros == 0
133 || claims.expires_at_micros <= claims.issued_at_micros
134 || claims.expires_at_micros - claims.issued_at_micros
135 > MAX_SUPERVISOR_ASSERTION_TTL_MICROS
136 {
137 return Err(InvalidError::new(
138 "supervisor assertion validity window is invalid",
139 ));
140 }
141 if self.key_id.len() != SUPERVISOR_ASSERTION_KEY_ID_BYTES
142 || self.signature.len() != SUPERVISOR_ASSERTION_SIGNATURE_BYTES
143 {
144 return Err(InvalidError::new(
145 "supervisor assertion key id or signature length is invalid",
146 ));
147 }
148 Ok(())
149 }
150}
151
152#[derive(
154 Clone,
155 Copy,
156 Debug,
157 PartialEq,
158 Eq,
159 Hash,
160 Serialize,
161 Deserialize,
162 strum::Display,
163 strum::EnumString,
164 strum::VariantArray,
165)]
166#[strum(serialize_all = "snake_case")]
167#[serde(rename_all = "snake_case")]
168#[non_exhaustive]
169pub enum Action {
170 Read,
171 Write,
172 Delete,
173 Admin,
174 #[serde(other)]
179 Unrecognized,
180}
181
182#[derive(
184 Clone,
185 Copy,
186 Debug,
187 Default,
188 PartialEq,
189 Eq,
190 Serialize,
191 Deserialize,
192 strum::Display,
193 strum::EnumString,
194 strum::VariantArray,
195)]
196#[strum(serialize_all = "snake_case")]
197#[serde(rename_all = "snake_case")]
198#[non_exhaustive]
199pub enum ResourceKind {
200 #[default]
202 All,
203 Literal,
205 Prefix,
207}
208
209#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
211pub struct ResourcePattern {
212 #[serde(default)]
213 pub kind: ResourceKind,
214 #[serde(default, skip_serializing_if = "String::is_empty")]
215 pub value: String,
216}
217
218impl ResourcePattern {
219 pub fn all() -> Self {
221 Self::default()
222 }
223
224 pub fn literal(value: impl Into<String>) -> Self {
226 Self {
227 kind: ResourceKind::Literal,
228 value: value.into(),
229 }
230 }
231
232 pub fn prefix(value: impl Into<String>) -> Self {
234 Self {
235 kind: ResourceKind::Prefix,
236 value: value.into(),
237 }
238 }
239
240 pub fn matches(&self, resource: Option<&str>) -> bool {
243 match (self.kind, resource) {
244 (ResourceKind::All, _) => true,
245 (ResourceKind::Literal, Some(r)) => r == self.value,
246 (ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
247 (_, None) => false,
248 }
249 }
250}
251
252#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
255pub struct Grant {
256 pub effect: Effect,
257 pub feature: Feature,
258 pub action: Action,
259 #[serde(default)]
260 pub resource: ResourcePattern,
261}
262
263#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
266pub struct Role {
267 pub name: String,
268 pub grants: Vec<Grant>,
269}
270
271#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
273pub struct RoleBinding {
274 pub user_id: u32,
275 pub roles: Vec<String>,
276}
277
278pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
285 crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
286}
287
288pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
292 let pair = match code {
293 AGDX_QUERY_CODE | AGDX_QUERY_PAGE_CODE | AGDX_QUERY_STATUS_CODE => {
294 (Feature::Query, Action::Read)
295 }
296 AGDX_QUERY_CANCEL_CODE => (Feature::Query, Action::Delete),
297 AGDX_DESTINATION_GET_CODE | AGDX_DESTINATION_LIST_CODE | AGDX_QUERY_ROUTE_LIST_CODE => {
298 (Feature::Destination, Action::Read)
299 }
300 AGDX_CHECKPOINT_CODE => (Feature::Checkpoint, Action::Write),
301 AGDX_GET_PROJECTION_CODE
302 | AGDX_LIST_PROJECTIONS_CODE
303 | AGDX_GET_SCHEMA_CODE
304 | AGDX_LIST_SCHEMAS_CODE
305 | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
306 AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
307 AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
308 (Feature::Kv, Action::Read)
309 }
310 AGDX_KV_SET_CODE
311 | AGDX_KV_CAS_CODE
312 | AGDX_KV_CAS_FENCED_CODE
313 | AGDX_KV_PATCH_CODE
314 | AGDX_KV_EXPIRE_CODE
315 | AGDX_KV_COPY_CODE
316 | AGDX_KV_MOVE_CODE
317 | AGDX_KV_LEASE_CODE
318 | AGDX_KV_RELEASE_CODE => (Feature::Kv, Action::Write),
319 AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
320 AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
321 AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
322 AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
323 AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
324 AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
325 AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
326 AGDX_AGENT_STATUS_CODE | AGDX_AGENT_LIST_CODE => (Feature::Agent, Action::Read),
327 AGDX_AGENT_SUBMIT_CODE => (Feature::Agent, Action::Write),
328 AGDX_AGENT_CANCEL_CODE => (Feature::Agent, Action::Delete),
329 _ => return None,
330 };
331 Some(pair)
332}
333
334pub const ACTION_COUNT: usize = 5;
339
340pub fn action_index(feature: Feature, action: Action) -> usize {
345 feature as usize * ACTION_COUNT + action as usize
346}
347
348const _: () = {
354 assert!(
355 <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
356 "ACTION_COUNT must equal the number of Action variants"
357 );
358 assert!(
359 <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 64,
360 "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 64 bits"
361 );
362};
363
364pub fn grants_allow(
368 grants: &[Grant],
369 feature: Feature,
370 action: Action,
371 resource: Option<&str>,
372) -> bool {
373 let mut allowed = false;
374 for grant in grants {
375 if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
376 match grant.effect {
377 Effect::Deny => return false,
378 Effect::Allow => allowed = true,
379 }
380 }
381 }
382 allowed
383}
384
385pub fn delegated_allow(
389 agent: &[Grant],
390 user: &[Grant],
391 feature: Feature,
392 action: Action,
393 resource: Option<&str>,
394) -> bool {
395 grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
396}
397
398#[derive(Clone, Debug, Serialize, Deserialize)]
400pub struct WhoamiReq {
401 pub v: u32,
402}
403
404#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
406pub struct WhoamiReply {
407 pub v: u32,
408 pub roles: Vec<String>,
409 pub grants: Vec<Grant>,
410}
411
412#[derive(Clone, Debug, Serialize, Deserialize)]
415pub struct ListRolesReq {
416 pub v: u32,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub name_prefix: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub search: Option<String>,
421}
422
423#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
425pub struct ListRolesReply {
426 pub v: u32,
427 pub roles: Vec<Role>,
428}
429
430#[derive(Clone, Debug, Serialize, Deserialize)]
432pub struct GetRoleReq {
433 pub v: u32,
434 pub name: String,
435}
436
437#[derive(Clone, Debug, Serialize, Deserialize)]
439pub struct GetBindingsReq {
440 pub v: u32,
441 pub user_id: u32,
442}
443
444#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
446pub struct BindingsReply {
447 pub v: u32,
448 pub roles: Vec<String>,
449}
450
451#[derive(Clone, Debug, Serialize, Deserialize)]
453pub struct DefineRoleReq {
454 pub v: u32,
455 pub role: Role,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub mutation_id: Option<String>,
458}
459
460#[derive(Clone, Debug, Serialize, Deserialize)]
462pub struct DeleteRoleReq {
463 pub v: u32,
464 pub name: String,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub mutation_id: Option<String>,
467}
468
469#[derive(Clone, Debug, Serialize, Deserialize)]
471pub struct BindRolesReq {
472 pub v: u32,
473 pub user_id: u32,
474 pub roles: Vec<String>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
481 pub expect_revision: Option<u64>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub mutation_id: Option<String>,
484}
485
486#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum AuthzSubject {
490 Role(String),
492 Binding { user_id: u32 },
494 All,
496}
497
498#[derive(Clone, Debug, Serialize, Deserialize)]
501pub struct AuthzHistoryReq {
502 pub v: u32,
503 pub subject: AuthzSubject,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub after_revision: Option<u64>,
506 pub limit: u32,
507}
508
509#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(rename_all = "snake_case")]
512pub enum AuthzEventKind {
513 RoleDefined(String),
515 RoleDeleted(String),
517 RolesBound { user_id: u32, roles: Vec<String> },
519}
520
521#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
523pub struct AuthzEvent {
524 pub revision: u64,
525 pub actor: String,
526 pub at_micros: u64,
527 pub op: AuthzEventKind,
528}
529
530#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
532pub struct AuthzHistoryReply {
533 pub v: u32,
534 pub events: Vec<AuthzEvent>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub next_after_revision: Option<u64>,
537}
538
539#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
541#[non_exhaustive]
542pub enum AuthzReply {
543 Ok,
545 Whoami(WhoamiReply),
547 Roles(ListRolesReply),
549 Role(Option<Role>),
551 Bindings(BindingsReply),
553 History(AuthzHistoryReply),
555 Err(AuthzError),
556}
557
558#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
560#[non_exhaustive]
561pub enum AuthzError {
562 #[error("authz not supported: {0}")]
563 Unsupported(String),
564 #[error("unauthorized")]
565 Unauthorized,
566 #[error("unknown role: {0}")]
567 UnknownRole(String),
568 #[error("invalid role name: {0}")]
570 InvalidName(String),
571 #[error("revision conflict: current is {current_revision}")]
575 Conflict { current_revision: u64 },
576 #[error("unsupported authz op version (expected {expected}, got {got})")]
577 Version { expected: u32, got: u32 },
578}
579
580#[cfg(all(test, feature = "cbor"))]
581mod tests {
582 use super::*;
583 use crate::framing::{decode_named, encode_named};
584
585 #[test]
586 fn given_a_role_when_round_tripped_then_should_preserve_grants() {
587 let role = Role {
588 name: "kv-reader".to_string(),
589 grants: vec![
590 Grant {
591 effect: Effect::Allow,
592 feature: Feature::Kv,
593 action: Action::Read,
594 resource: ResourcePattern::prefix("agent-abc/"),
595 },
596 Grant {
597 effect: Effect::Deny,
598 feature: Feature::Kv,
599 action: Action::Read,
600 resource: ResourcePattern::literal("agent-abc/secret"),
601 },
602 ],
603 };
604 let bytes = encode_named(&role).expect("role serializes");
605 let back: Role = decode_named(&bytes).expect("role deserializes");
606 assert_eq!(back, role);
607 }
608
609 #[test]
610 fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
611 assert!(validate_role_name("kv-reader").is_ok());
612 assert!(validate_role_name("ops.admin_2").is_ok());
613 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
614 assert!(validate_role_name("").is_err(), "empty");
615 assert!(validate_role_name("bad name").is_err(), "space");
616 assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
617 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
618 }
619
620 #[test]
621 fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
622 assert!(ResourcePattern::all().matches(Some("anything")));
623 assert!(ResourcePattern::all().matches(None));
624 assert!(ResourcePattern::literal("ns").matches(Some("ns")));
625 assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
626 assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
627 assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
628 assert!(!ResourcePattern::literal("ns").matches(None));
631 assert!(!ResourcePattern::prefix("agent-").matches(None));
632 }
633
634 #[test]
635 fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
636 let allow = |feature, action, resource| Grant {
637 effect: Effect::Allow,
638 feature,
639 action,
640 resource,
641 };
642 let agent = vec![
645 allow(Feature::Kv, Action::Read, ResourcePattern::all()),
646 allow(Feature::Kv, Action::Write, ResourcePattern::all()),
647 ];
648 let user = vec![allow(
649 Feature::Kv,
650 Action::Read,
651 ResourcePattern::prefix("shared/"),
652 )];
653 assert!(delegated_allow(
654 &agent,
655 &user,
656 Feature::Kv,
657 Action::Read,
658 Some("shared/x")
659 ));
660 assert!(!delegated_allow(
662 &agent,
663 &user,
664 Feature::Kv,
665 Action::Read,
666 Some("private/x")
667 ));
668 assert!(!delegated_allow(
670 &agent,
671 &user,
672 Feature::Kv,
673 Action::Write,
674 Some("shared/x")
675 ));
676 assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
678 }
679
680 #[test]
681 fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
682 assert_eq!(
683 feature_action(AGDX_KV_GET_CODE),
684 Some((Feature::Kv, Action::Read))
685 );
686 assert_eq!(
687 feature_action(AGDX_KV_SET_CODE),
688 Some((Feature::Kv, Action::Write))
689 );
690 assert_eq!(
691 feature_action(AGDX_KV_DELETE_CODE),
692 Some((Feature::Kv, Action::Delete))
693 );
694 assert_eq!(
695 feature_action(AGDX_REGISTER_SCHEMA_CODE),
696 Some((Feature::Projection, Action::Admin))
697 );
698 assert_eq!(
699 feature_action(AGDX_QUERY_CODE),
700 Some((Feature::Query, Action::Read))
701 );
702 assert_eq!(
703 feature_action(AGDX_GRAPH_UPSERT_CODE),
704 Some((Feature::Graph, Action::Write))
705 );
706 assert_eq!(feature_action(AGDX_HELLO_CODE), None);
708 assert_eq!(feature_action(AGDX_BATCH_CODE), None);
709 assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
710 }
711
712 #[test]
713 fn given_feature_action_pairs_when_indexed_then_should_fit_a_u64_mask() {
714 use strum::VariantArray;
715 let mut seen = std::collections::HashSet::new();
716 for &feature in Feature::VARIANTS {
717 for &action in Action::VARIANTS {
718 let index = action_index(feature, action);
719 assert!(index < 64, "index {index} must fit a u64 mask");
720 assert!(seen.insert(index), "index {index} collided");
721 }
722 }
723 }
724
725 #[test]
726 fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
727 let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
730 let grant: Grant =
731 serde_json::from_str(json).expect("an unknown feature/action still decodes");
732 assert_eq!(grant.feature, Feature::Unrecognized);
733 assert_eq!(grant.action, Action::Unrecognized);
734 assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
738 assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
742 assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
743 assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
744 assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
745 }
746
747 #[test]
748 fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
749 let reply = AuthzReply::Whoami(WhoamiReply {
750 v: AUTHZ_OP_VERSION,
751 roles: vec!["admin".to_string()],
752 grants: vec![Grant {
753 effect: Effect::Allow,
754 feature: Feature::Kv,
755 action: Action::Write,
756 resource: ResourcePattern::all(),
757 }],
758 });
759 let bytes = encode_named(&reply).expect("reply serializes");
760 let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
761 assert_eq!(back, reply);
762 }
763}