use crate::codes::*;
use crate::error::InvalidError;
use crate::limits::MAX_ROLE_NAME_BYTES;
use serde::{Deserialize, Serialize};
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Effect {
#[default]
Allow,
Deny,
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Feature {
Kv,
Memory,
Projection,
Fork,
Graph,
Query,
Agent,
Workflow,
Authz,
#[serde(other)]
Unrecognized,
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Action {
Read,
Write,
Delete,
Admin,
#[serde(other)]
Unrecognized,
}
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::VariantArray,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResourceKind {
#[default]
All,
Literal,
Prefix,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourcePattern {
#[serde(default)]
pub kind: ResourceKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub value: String,
}
impl ResourcePattern {
pub fn all() -> Self {
Self::default()
}
pub fn literal(value: impl Into<String>) -> Self {
Self {
kind: ResourceKind::Literal,
value: value.into(),
}
}
pub fn prefix(value: impl Into<String>) -> Self {
Self {
kind: ResourceKind::Prefix,
value: value.into(),
}
}
pub fn matches(&self, resource: Option<&str>) -> bool {
match (self.kind, resource) {
(ResourceKind::All, _) => true,
(ResourceKind::Literal, Some(r)) => r == self.value,
(ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
(_, None) => false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grant {
pub effect: Effect,
pub feature: Feature,
pub action: Action,
#[serde(default)]
pub resource: ResourcePattern,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Role {
pub name: String,
pub grants: Vec<Grant>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoleBinding {
pub user_id: u32,
pub roles: Vec<String>,
}
pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
}
pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
let pair = match code {
AGDX_QUERY_CODE => (Feature::Query, Action::Read),
AGDX_GET_PROJECTION_CODE
| AGDX_LIST_PROJECTIONS_CODE
| AGDX_GET_SCHEMA_CODE
| AGDX_LIST_SCHEMAS_CODE
| AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
(Feature::Kv, Action::Read)
}
AGDX_KV_SET_CODE
| AGDX_KV_CAS_CODE
| AGDX_KV_CAS_FENCED_CODE
| AGDX_KV_PATCH_CODE
| AGDX_KV_EXPIRE_CODE
| AGDX_KV_COPY_CODE
| AGDX_KV_MOVE_CODE
| AGDX_KV_LEASE_CODE
| AGDX_KV_RELEASE_CODE => (Feature::Kv, Action::Write),
AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
AGDX_AGENT_STATUS_CODE | AGDX_AGENT_LIST_CODE => (Feature::Agent, Action::Read),
AGDX_AGENT_SUBMIT_CODE => (Feature::Agent, Action::Write),
AGDX_AGENT_CANCEL_CODE => (Feature::Agent, Action::Delete),
_ => return None,
};
Some(pair)
}
pub const ACTION_COUNT: usize = 5;
pub fn action_index(feature: Feature, action: Action) -> usize {
feature as usize * ACTION_COUNT + action as usize
}
const _: () = {
assert!(
<Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
"ACTION_COUNT must equal the number of Action variants"
);
assert!(
<Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 64,
"authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 64 bits"
);
};
pub fn grants_allow(
grants: &[Grant],
feature: Feature,
action: Action,
resource: Option<&str>,
) -> bool {
let mut allowed = false;
for grant in grants {
if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
match grant.effect {
Effect::Deny => return false,
Effect::Allow => allowed = true,
}
}
}
allowed
}
pub fn delegated_allow(
agent: &[Grant],
user: &[Grant],
feature: Feature,
action: Action,
resource: Option<&str>,
) -> bool {
grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WhoamiReq {
pub v: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WhoamiReply {
pub v: u32,
pub roles: Vec<String>,
pub grants: Vec<Grant>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ListRolesReq {
pub v: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListRolesReply {
pub v: u32,
pub roles: Vec<Role>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GetRoleReq {
pub v: u32,
pub name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GetBindingsReq {
pub v: u32,
pub user_id: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BindingsReply {
pub v: u32,
pub roles: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DefineRoleReq {
pub v: u32,
pub role: Role,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeleteRoleReq {
pub v: u32,
pub name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BindRolesReq {
pub v: u32,
pub user_id: u32,
pub roles: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expect_revision: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthzSubject {
Role(String),
Binding { user_id: u32 },
All,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthzHistoryReq {
pub v: u32,
pub subject: AuthzSubject,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_revision: Option<u64>,
pub limit: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthzEventKind {
RoleDefined(String),
RoleDeleted(String),
RolesBound { user_id: u32, roles: Vec<String> },
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthzEvent {
pub revision: u64,
pub actor: String,
pub at_micros: u64,
pub op: AuthzEventKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthzHistoryReply {
pub v: u32,
pub events: Vec<AuthzEvent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_after_revision: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AuthzReply {
Ok,
Whoami(WhoamiReply),
Roles(ListRolesReply),
Role(Option<Role>),
Bindings(BindingsReply),
History(AuthzHistoryReply),
Err(AuthzError),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[non_exhaustive]
pub enum AuthzError {
#[error("authz not supported: {0}")]
Unsupported(String),
#[error("unauthorized")]
Unauthorized,
#[error("unknown role: {0}")]
UnknownRole(String),
#[error("invalid role name: {0}")]
InvalidName(String),
#[error("revision conflict: current is {current_revision}")]
Conflict { current_revision: u64 },
#[error("unsupported authz op version (expected {expected}, got {got})")]
Version { expected: u32, got: u32 },
}
#[cfg(all(test, feature = "cbor"))]
mod tests {
use super::*;
use crate::framing::{decode_named, encode_named};
#[test]
fn given_a_role_when_round_tripped_then_should_preserve_grants() {
let role = Role {
name: "kv-reader".to_string(),
grants: vec![
Grant {
effect: Effect::Allow,
feature: Feature::Kv,
action: Action::Read,
resource: ResourcePattern::prefix("agent-abc/"),
},
Grant {
effect: Effect::Deny,
feature: Feature::Kv,
action: Action::Read,
resource: ResourcePattern::literal("agent-abc/secret"),
},
],
};
let bytes = encode_named(&role).expect("role serializes");
let back: Role = decode_named(&bytes).expect("role deserializes");
assert_eq!(back, role);
}
#[test]
fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
assert!(validate_role_name("kv-reader").is_ok());
assert!(validate_role_name("ops.admin_2").is_ok());
assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
assert!(validate_role_name("").is_err(), "empty");
assert!(validate_role_name("bad name").is_err(), "space");
assert!(validate_role_name("rôle").is_err(), "non-ascii");
assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
}
#[test]
fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
assert!(ResourcePattern::all().matches(Some("anything")));
assert!(ResourcePattern::all().matches(None));
assert!(ResourcePattern::literal("ns").matches(Some("ns")));
assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
assert!(!ResourcePattern::literal("ns").matches(None));
assert!(!ResourcePattern::prefix("agent-").matches(None));
}
#[test]
fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
let allow = |feature, action, resource| Grant {
effect: Effect::Allow,
feature,
action,
resource,
};
let agent = vec![
allow(Feature::Kv, Action::Read, ResourcePattern::all()),
allow(Feature::Kv, Action::Write, ResourcePattern::all()),
];
let user = vec![allow(
Feature::Kv,
Action::Read,
ResourcePattern::prefix("shared/"),
)];
assert!(delegated_allow(
&agent,
&user,
Feature::Kv,
Action::Read,
Some("shared/x")
));
assert!(!delegated_allow(
&agent,
&user,
Feature::Kv,
Action::Read,
Some("private/x")
));
assert!(!delegated_allow(
&agent,
&user,
Feature::Kv,
Action::Write,
Some("shared/x")
));
assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
}
#[test]
fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
assert_eq!(
feature_action(AGDX_KV_GET_CODE),
Some((Feature::Kv, Action::Read))
);
assert_eq!(
feature_action(AGDX_KV_SET_CODE),
Some((Feature::Kv, Action::Write))
);
assert_eq!(
feature_action(AGDX_KV_DELETE_CODE),
Some((Feature::Kv, Action::Delete))
);
assert_eq!(
feature_action(AGDX_REGISTER_SCHEMA_CODE),
Some((Feature::Projection, Action::Admin))
);
assert_eq!(
feature_action(AGDX_QUERY_CODE),
Some((Feature::Query, Action::Read))
);
assert_eq!(
feature_action(AGDX_GRAPH_UPSERT_CODE),
Some((Feature::Graph, Action::Write))
);
assert_eq!(feature_action(AGDX_HELLO_CODE), None);
assert_eq!(feature_action(AGDX_BATCH_CODE), None);
assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
}
#[test]
fn given_feature_action_pairs_when_indexed_then_should_fit_a_u64_mask() {
use strum::VariantArray;
let mut seen = std::collections::HashSet::new();
for &feature in Feature::VARIANTS {
for &action in Action::VARIANTS {
let index = action_index(feature, action);
assert!(index < 64, "index {index} must fit a u64 mask");
assert!(seen.insert(index), "index {index} collided");
}
}
}
#[test]
fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
let grant: Grant =
serde_json::from_str(json).expect("an unknown feature/action still decodes");
assert_eq!(grant.feature, Feature::Unrecognized);
assert_eq!(grant.action, Action::Unrecognized);
assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
}
#[test]
fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
let reply = AuthzReply::Whoami(WhoamiReply {
v: AUTHZ_OP_VERSION,
roles: vec!["admin".to_string()],
grants: vec![Grant {
effect: Effect::Allow,
feature: Feature::Kv,
action: Action::Write,
resource: ResourcePattern::all(),
}],
});
let bytes = encode_named(&reply).expect("reply serializes");
let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
assert_eq!(back, reply);
}
}