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}
368
369#[derive(Clone, Debug, Serialize, Deserialize)]
371pub struct DeleteRoleReq {
372 pub v: u32,
373 pub name: String,
374}
375
376#[derive(Clone, Debug, Serialize, Deserialize)]
378pub struct BindRolesReq {
379 pub v: u32,
380 pub user_id: u32,
381 pub roles: Vec<String>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub expect_revision: Option<u64>,
389}
390
391#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum AuthzSubject {
395 Role(String),
397 Binding { user_id: u32 },
399 All,
401}
402
403#[derive(Clone, Debug, Serialize, Deserialize)]
406pub struct AuthzHistoryReq {
407 pub v: u32,
408 pub subject: AuthzSubject,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub after_revision: Option<u64>,
411 pub limit: u32,
412}
413
414#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "snake_case")]
417pub enum AuthzEventKind {
418 RoleDefined(String),
420 RoleDeleted(String),
422 RolesBound { user_id: u32, roles: Vec<String> },
424}
425
426#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
428pub struct AuthzEvent {
429 pub revision: u64,
430 pub actor: String,
431 pub at_micros: u64,
432 pub op: AuthzEventKind,
433}
434
435#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
437pub struct AuthzHistoryReply {
438 pub v: u32,
439 pub events: Vec<AuthzEvent>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub next_after_revision: Option<u64>,
442}
443
444#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
446#[non_exhaustive]
447pub enum AuthzReply {
448 Ok,
450 Whoami(WhoamiReply),
452 Roles(ListRolesReply),
454 Role(Option<Role>),
456 Bindings(BindingsReply),
458 History(AuthzHistoryReply),
460 Err(AuthzError),
461}
462
463#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
465#[non_exhaustive]
466pub enum AuthzError {
467 #[error("authz not supported: {0}")]
468 Unsupported(String),
469 #[error("unauthorized")]
470 Unauthorized,
471 #[error("unknown role: {0}")]
472 UnknownRole(String),
473 #[error("invalid role name: {0}")]
475 InvalidName(String),
476 #[error("revision conflict: current is {current_revision}")]
480 Conflict { current_revision: u64 },
481 #[error("unsupported authz op version (expected {expected}, got {got})")]
482 Version { expected: u32, got: u32 },
483}
484
485#[cfg(all(test, feature = "cbor"))]
486mod tests {
487 use super::*;
488 use crate::framing::{decode_named, encode_named};
489
490 #[test]
491 fn given_a_role_when_round_tripped_then_should_preserve_grants() {
492 let role = Role {
493 name: "kv-reader".to_string(),
494 grants: vec![
495 Grant {
496 effect: Effect::Allow,
497 feature: Feature::Kv,
498 action: Action::Read,
499 resource: ResourcePattern::prefix("agent-abc/"),
500 },
501 Grant {
502 effect: Effect::Deny,
503 feature: Feature::Kv,
504 action: Action::Read,
505 resource: ResourcePattern::literal("agent-abc/secret"),
506 },
507 ],
508 };
509 let bytes = encode_named(&role).expect("role serializes");
510 let back: Role = decode_named(&bytes).expect("role deserializes");
511 assert_eq!(back, role);
512 }
513
514 #[test]
515 fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
516 assert!(validate_role_name("kv-reader").is_ok());
517 assert!(validate_role_name("ops.admin_2").is_ok());
518 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
519 assert!(validate_role_name("").is_err(), "empty");
520 assert!(validate_role_name("bad name").is_err(), "space");
521 assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
522 assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
523 }
524
525 #[test]
526 fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
527 assert!(ResourcePattern::all().matches(Some("anything")));
528 assert!(ResourcePattern::all().matches(None));
529 assert!(ResourcePattern::literal("ns").matches(Some("ns")));
530 assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
531 assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
532 assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
533 assert!(!ResourcePattern::literal("ns").matches(None));
536 assert!(!ResourcePattern::prefix("agent-").matches(None));
537 }
538
539 #[test]
540 fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
541 let allow = |feature, action, resource| Grant {
542 effect: Effect::Allow,
543 feature,
544 action,
545 resource,
546 };
547 let agent = vec![
550 allow(Feature::Kv, Action::Read, ResourcePattern::all()),
551 allow(Feature::Kv, Action::Write, ResourcePattern::all()),
552 ];
553 let user = vec![allow(
554 Feature::Kv,
555 Action::Read,
556 ResourcePattern::prefix("shared/"),
557 )];
558 assert!(delegated_allow(
559 &agent,
560 &user,
561 Feature::Kv,
562 Action::Read,
563 Some("shared/x")
564 ));
565 assert!(!delegated_allow(
567 &agent,
568 &user,
569 Feature::Kv,
570 Action::Read,
571 Some("private/x")
572 ));
573 assert!(!delegated_allow(
575 &agent,
576 &user,
577 Feature::Kv,
578 Action::Write,
579 Some("shared/x")
580 ));
581 assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
583 }
584
585 #[test]
586 fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
587 assert_eq!(
588 feature_action(AGDX_KV_GET_CODE),
589 Some((Feature::Kv, Action::Read))
590 );
591 assert_eq!(
592 feature_action(AGDX_KV_SET_CODE),
593 Some((Feature::Kv, Action::Write))
594 );
595 assert_eq!(
596 feature_action(AGDX_KV_DELETE_CODE),
597 Some((Feature::Kv, Action::Delete))
598 );
599 assert_eq!(
600 feature_action(AGDX_REGISTER_SCHEMA_CODE),
601 Some((Feature::Projection, Action::Admin))
602 );
603 assert_eq!(
604 feature_action(AGDX_QUERY_CODE),
605 Some((Feature::Query, Action::Read))
606 );
607 assert_eq!(
608 feature_action(AGDX_GRAPH_UPSERT_CODE),
609 Some((Feature::Graph, Action::Write))
610 );
611 assert_eq!(feature_action(AGDX_HELLO_CODE), None);
613 assert_eq!(feature_action(AGDX_BATCH_CODE), None);
614 assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
615 }
616
617 #[test]
618 fn given_feature_action_pairs_when_indexed_then_should_fit_a_u64_mask() {
619 use strum::VariantArray;
620 let mut seen = std::collections::HashSet::new();
621 for &feature in Feature::VARIANTS {
622 for &action in Action::VARIANTS {
623 let index = action_index(feature, action);
624 assert!(index < 64, "index {index} must fit a u64 mask");
625 assert!(seen.insert(index), "index {index} collided");
626 }
627 }
628 }
629
630 #[test]
631 fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
632 let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
635 let grant: Grant =
636 serde_json::from_str(json).expect("an unknown feature/action still decodes");
637 assert_eq!(grant.feature, Feature::Unrecognized);
638 assert_eq!(grant.action, Action::Unrecognized);
639 assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
643 assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
647 assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
648 assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
649 assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
650 }
651
652 #[test]
653 fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
654 let reply = AuthzReply::Whoami(WhoamiReply {
655 v: AUTHZ_OP_VERSION,
656 roles: vec!["admin".to_string()],
657 grants: vec![Grant {
658 effect: Effect::Allow,
659 feature: Feature::Kv,
660 action: Action::Write,
661 resource: ResourcePattern::all(),
662 }],
663 });
664 let bytes = encode_named(&reply).expect("reply serializes");
665 let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
666 assert_eq!(back, reply);
667 }
668}