1use crate::codes::*;
2use crate::error::InvalidError;
3use crate::limits::MAX_ROLE_NAME_BYTES;
4use serde::{Deserialize, Serialize};
5
6#[derive(
8 Clone,
9 Copy,
10 Debug,
11 Default,
12 PartialEq,
13 Eq,
14 Serialize,
15 Deserialize,
16 strum::Display,
17 strum::EnumString,
18 strum::VariantArray,
19)]
20#[strum(serialize_all = "snake_case")]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum Effect {
24 #[default]
25 Allow,
26 Deny,
27}
28
29#[derive(
32 Clone,
33 Copy,
34 Debug,
35 PartialEq,
36 Eq,
37 Hash,
38 Serialize,
39 Deserialize,
40 strum::Display,
41 strum::EnumString,
42 strum::VariantArray,
43)]
44#[strum(serialize_all = "snake_case")]
45#[serde(rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum Feature {
48 Kv,
49 Memory,
50 Projection,
51 Fork,
52 Graph,
53 Query,
54 Agent,
55 Workflow,
56 Authz,
59 #[serde(other)]
67 Unrecognized,
68}
69
70#[derive(
72 Clone,
73 Copy,
74 Debug,
75 PartialEq,
76 Eq,
77 Hash,
78 Serialize,
79 Deserialize,
80 strum::Display,
81 strum::EnumString,
82 strum::VariantArray,
83)]
84#[strum(serialize_all = "snake_case")]
85#[serde(rename_all = "snake_case")]
86#[non_exhaustive]
87pub enum Action {
88 Read,
89 Write,
90 Delete,
91 Admin,
92 #[serde(other)]
97 Unrecognized,
98}
99
100#[derive(
102 Clone,
103 Copy,
104 Debug,
105 Default,
106 PartialEq,
107 Eq,
108 Serialize,
109 Deserialize,
110 strum::Display,
111 strum::EnumString,
112 strum::VariantArray,
113)]
114#[strum(serialize_all = "snake_case")]
115#[serde(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum ResourceKind {
118 #[default]
120 All,
121 Literal,
123 Prefix,
125}
126
127#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
129pub struct ResourcePattern {
130 #[serde(default)]
131 pub kind: ResourceKind,
132 #[serde(default, skip_serializing_if = "String::is_empty")]
133 pub value: String,
134}
135
136impl ResourcePattern {
137 pub fn all() -> Self {
139 Self::default()
140 }
141
142 pub fn literal(value: impl Into<String>) -> Self {
144 Self {
145 kind: ResourceKind::Literal,
146 value: value.into(),
147 }
148 }
149
150 pub fn prefix(value: impl Into<String>) -> Self {
152 Self {
153 kind: ResourceKind::Prefix,
154 value: value.into(),
155 }
156 }
157
158 pub fn matches(&self, resource: Option<&str>) -> bool {
161 match (self.kind, resource) {
162 (ResourceKind::All, _) => true,
163 (ResourceKind::Literal, Some(r)) => r == self.value,
164 (ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
165 (_, None) => false,
166 }
167 }
168}
169
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
173pub struct Grant {
174 pub effect: Effect,
175 pub feature: Feature,
176 pub action: Action,
177 #[serde(default)]
178 pub resource: ResourcePattern,
179}
180
181#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Role {
185 pub name: String,
186 pub grants: Vec<Grant>,
187}
188
189#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
191pub struct RoleBinding {
192 pub user_id: u32,
193 pub roles: Vec<String>,
194}
195
196pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
203 crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
204}
205
206pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
210 let pair = match code {
211 AGDX_QUERY_CODE => (Feature::Query, Action::Read),
212 AGDX_GET_PROJECTION_CODE
213 | AGDX_LIST_PROJECTIONS_CODE
214 | AGDX_GET_SCHEMA_CODE
215 | AGDX_LIST_SCHEMAS_CODE
216 | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
217 AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
218 AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
219 (Feature::Kv, Action::Read)
220 }
221 AGDX_KV_SET_CODE
222 | AGDX_KV_CAS_CODE
223 | AGDX_KV_CAS_FENCED_CODE
224 | AGDX_KV_PATCH_CODE
225 | AGDX_KV_EXPIRE_CODE
226 | AGDX_KV_COPY_CODE
227 | AGDX_KV_MOVE_CODE
228 | AGDX_KV_LEASE_CODE
229 | AGDX_KV_RELEASE_CODE => (Feature::Kv, Action::Write),
230 AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
231 AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
232 AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
233 AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
234 AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
235 AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
236 AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
237 AGDX_AGENT_STATUS_CODE | AGDX_AGENT_LIST_CODE => (Feature::Agent, Action::Read),
238 AGDX_AGENT_SUBMIT_CODE => (Feature::Agent, Action::Write),
239 AGDX_AGENT_CANCEL_CODE => (Feature::Agent, Action::Delete),
240 _ => return None,
241 };
242 Some(pair)
243}
244
245pub const ACTION_COUNT: usize = 5;
250
251pub fn action_index(feature: Feature, action: Action) -> usize {
256 feature as usize * ACTION_COUNT + action as usize
257}
258
259const _: () = {
265 assert!(
266 <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
267 "ACTION_COUNT must equal the number of Action variants"
268 );
269 assert!(
270 <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 64,
271 "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 64 bits"
272 );
273};
274
275pub fn grants_allow(
279 grants: &[Grant],
280 feature: Feature,
281 action: Action,
282 resource: Option<&str>,
283) -> bool {
284 let mut allowed = false;
285 for grant in grants {
286 if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
287 match grant.effect {
288 Effect::Deny => return false,
289 Effect::Allow => allowed = true,
290 }
291 }
292 }
293 allowed
294}
295
296pub fn delegated_allow(
300 agent: &[Grant],
301 user: &[Grant],
302 feature: Feature,
303 action: Action,
304 resource: Option<&str>,
305) -> bool {
306 grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
307}
308
309#[derive(Clone, Debug, Serialize, Deserialize)]
311pub struct WhoamiReq {
312 pub v: u32,
313}
314
315#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
317pub struct WhoamiReply {
318 pub v: u32,
319 pub roles: Vec<String>,
320 pub grants: Vec<Grant>,
321}
322
323#[derive(Clone, Debug, Serialize, Deserialize)]
326pub struct ListRolesReq {
327 pub v: u32,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub name_prefix: Option<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub search: Option<String>,
332}
333
334#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
336pub struct ListRolesReply {
337 pub v: u32,
338 pub roles: Vec<Role>,
339}
340
341#[derive(Clone, Debug, Serialize, Deserialize)]
343pub struct GetRoleReq {
344 pub v: u32,
345 pub name: String,
346}
347
348#[derive(Clone, Debug, Serialize, Deserialize)]
350pub struct GetBindingsReq {
351 pub v: u32,
352 pub user_id: u32,
353}
354
355#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
357pub struct BindingsReply {
358 pub v: u32,
359 pub roles: Vec<String>,
360}
361
362#[derive(Clone, Debug, Serialize, Deserialize)]
364pub struct DefineRoleReq {
365 pub v: u32,
366 pub role: Role,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub mutation_id: Option<String>,
369}
370
371#[derive(Clone, Debug, Serialize, Deserialize)]
373pub struct DeleteRoleReq {
374 pub v: u32,
375 pub name: String,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub mutation_id: Option<String>,
378}
379
380#[derive(Clone, Debug, Serialize, Deserialize)]
382pub struct BindRolesReq {
383 pub v: u32,
384 pub user_id: u32,
385 pub roles: Vec<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub expect_revision: Option<u64>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub mutation_id: Option<String>,
395}
396
397#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400pub enum AuthzSubject {
401 Role(String),
403 Binding { user_id: u32 },
405 All,
407}
408
409#[derive(Clone, Debug, Serialize, Deserialize)]
412pub struct AuthzHistoryReq {
413 pub v: u32,
414 pub subject: AuthzSubject,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub after_revision: Option<u64>,
417 pub limit: u32,
418}
419
420#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(rename_all = "snake_case")]
423pub enum AuthzEventKind {
424 RoleDefined(String),
426 RoleDeleted(String),
428 RolesBound { user_id: u32, roles: Vec<String> },
430}
431
432#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
434pub struct AuthzEvent {
435 pub revision: u64,
436 pub actor: String,
437 pub at_micros: u64,
438 pub op: AuthzEventKind,
439}
440
441#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
443pub struct AuthzHistoryReply {
444 pub v: u32,
445 pub events: Vec<AuthzEvent>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub next_after_revision: Option<u64>,
448}
449
450#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
452#[non_exhaustive]
453pub enum AuthzReply {
454 Ok,
456 Whoami(WhoamiReply),
458 Roles(ListRolesReply),
460 Role(Option<Role>),
462 Bindings(BindingsReply),
464 History(AuthzHistoryReply),
466 Err(AuthzError),
467}
468
469#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
471#[non_exhaustive]
472pub enum AuthzError {
473 #[error("authz not supported: {0}")]
474 Unsupported(String),
475 #[error("unauthorized")]
476 Unauthorized,
477 #[error("unknown role: {0}")]
478 UnknownRole(String),
479 #[error("invalid role name: {0}")]
481 InvalidName(String),
482 #[error("revision conflict: current is {current_revision}")]
486 Conflict { current_revision: u64 },
487 #[error("unsupported authz op version (expected {expected}, got {got})")]
488 Version { expected: u32, got: u32 },
489}
490
491#[cfg(all(test, feature = "cbor"))]
492mod tests {
493 use super::*;
494 use crate::framing::{decode_named, encode_named};
495
496 #[test]
497 fn given_a_role_when_round_tripped_then_should_preserve_grants() {
498 let role = Role {
499 name: "kv-reader".to_string(),
500 grants: vec![
501 Grant {
502 effect: Effect::Allow,
503 feature: Feature::Kv,
504 action: Action::Read,
505 resource: ResourcePattern::prefix("agent-abc/"),
506 },
507 Grant {
508 effect: Effect::Deny,
509 feature: Feature::Kv,
510 action: Action::Read,
511 resource: ResourcePattern::literal("agent-abc/secret"),
512 },
513 ],
514 };
515 let bytes = encode_named(&role).expect("role serializes");
516 let back: Role = decode_named(&bytes).expect("role deserializes");
517 assert_eq!(back, role);
518 }
519
520 #[test]
521 fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
522 assert!(validate_role_name("kv-reader").is_ok());
523 assert!(validate_role_name("ops.admin_2").is_ok());
524 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
525 assert!(validate_role_name("").is_err(), "empty");
526 assert!(validate_role_name("bad name").is_err(), "space");
527 assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
528 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
529 }
530
531 #[test]
532 fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
533 assert!(ResourcePattern::all().matches(Some("anything")));
534 assert!(ResourcePattern::all().matches(None));
535 assert!(ResourcePattern::literal("ns").matches(Some("ns")));
536 assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
537 assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
538 assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
539 assert!(!ResourcePattern::literal("ns").matches(None));
542 assert!(!ResourcePattern::prefix("agent-").matches(None));
543 }
544
545 #[test]
546 fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
547 let allow = |feature, action, resource| Grant {
548 effect: Effect::Allow,
549 feature,
550 action,
551 resource,
552 };
553 let agent = vec![
556 allow(Feature::Kv, Action::Read, ResourcePattern::all()),
557 allow(Feature::Kv, Action::Write, ResourcePattern::all()),
558 ];
559 let user = vec![allow(
560 Feature::Kv,
561 Action::Read,
562 ResourcePattern::prefix("shared/"),
563 )];
564 assert!(delegated_allow(
565 &agent,
566 &user,
567 Feature::Kv,
568 Action::Read,
569 Some("shared/x")
570 ));
571 assert!(!delegated_allow(
573 &agent,
574 &user,
575 Feature::Kv,
576 Action::Read,
577 Some("private/x")
578 ));
579 assert!(!delegated_allow(
581 &agent,
582 &user,
583 Feature::Kv,
584 Action::Write,
585 Some("shared/x")
586 ));
587 assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
589 }
590
591 #[test]
592 fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
593 assert_eq!(
594 feature_action(AGDX_KV_GET_CODE),
595 Some((Feature::Kv, Action::Read))
596 );
597 assert_eq!(
598 feature_action(AGDX_KV_SET_CODE),
599 Some((Feature::Kv, Action::Write))
600 );
601 assert_eq!(
602 feature_action(AGDX_KV_DELETE_CODE),
603 Some((Feature::Kv, Action::Delete))
604 );
605 assert_eq!(
606 feature_action(AGDX_REGISTER_SCHEMA_CODE),
607 Some((Feature::Projection, Action::Admin))
608 );
609 assert_eq!(
610 feature_action(AGDX_QUERY_CODE),
611 Some((Feature::Query, Action::Read))
612 );
613 assert_eq!(
614 feature_action(AGDX_GRAPH_UPSERT_CODE),
615 Some((Feature::Graph, Action::Write))
616 );
617 assert_eq!(feature_action(AGDX_HELLO_CODE), None);
619 assert_eq!(feature_action(AGDX_BATCH_CODE), None);
620 assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
621 }
622
623 #[test]
624 fn given_feature_action_pairs_when_indexed_then_should_fit_a_u64_mask() {
625 use strum::VariantArray;
626 let mut seen = std::collections::HashSet::new();
627 for &feature in Feature::VARIANTS {
628 for &action in Action::VARIANTS {
629 let index = action_index(feature, action);
630 assert!(index < 64, "index {index} must fit a u64 mask");
631 assert!(seen.insert(index), "index {index} collided");
632 }
633 }
634 }
635
636 #[test]
637 fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
638 let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
641 let grant: Grant =
642 serde_json::from_str(json).expect("an unknown feature/action still decodes");
643 assert_eq!(grant.feature, Feature::Unrecognized);
644 assert_eq!(grant.action, Action::Unrecognized);
645 assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
649 assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
653 assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
654 assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
655 assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
656 }
657
658 #[test]
659 fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
660 let reply = AuthzReply::Whoami(WhoamiReply {
661 v: AUTHZ_OP_VERSION,
662 roles: vec!["admin".to_string()],
663 grants: vec![Grant {
664 effect: Effect::Allow,
665 feature: Feature::Kv,
666 action: Action::Write,
667 resource: ResourcePattern::all(),
668 }],
669 });
670 let bytes = encode_named(&reply).expect("reply serializes");
671 let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
672 assert_eq!(back, reply);
673 }
674}