Implement OperationSpec, AccessControl, Visibility, ErrorDefinition
This commit is contained in:
@@ -4,4 +4,318 @@
|
|||||||
//! See `docs/architecture/crates/call/operation-registry.md` for the full
|
//! See `docs/architecture/crates/call/operation-registry.md` for the full
|
||||||
//! specification.
|
//! specification.
|
||||||
|
|
||||||
// TODO: implement
|
use alknet_core::auth::Identity;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OperationType {
|
||||||
|
Query,
|
||||||
|
Mutation,
|
||||||
|
Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Visibility {
|
||||||
|
External,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ErrorDefinition {
|
||||||
|
pub code: String,
|
||||||
|
pub description: String,
|
||||||
|
pub schema: Value,
|
||||||
|
pub http_status: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct AccessControl {
|
||||||
|
pub required_scopes: Vec<String>,
|
||||||
|
pub required_scopes_any: Option<Vec<String>>,
|
||||||
|
pub resource_type: Option<String>,
|
||||||
|
pub resource_action: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AccessResult {
|
||||||
|
Allowed,
|
||||||
|
Forbidden(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccessControl {
|
||||||
|
pub fn has_restrictions(&self) -> bool {
|
||||||
|
!self.required_scopes.is_empty()
|
||||||
|
|| self.required_scopes_any.is_some()
|
||||||
|
|| self.resource_type.is_some()
|
||||||
|
|| self.resource_action.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check(&self, identity: Option<&Identity>) -> AccessResult {
|
||||||
|
if !self.has_restrictions() {
|
||||||
|
return AccessResult::Allowed;
|
||||||
|
}
|
||||||
|
let identity = match identity {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return AccessResult::Forbidden("authentication required".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
for scope in &self.required_scopes {
|
||||||
|
if !identity.scopes.iter().any(|s| s == scope) {
|
||||||
|
return AccessResult::Forbidden(format!("missing required scope: {scope}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(any) = &self.required_scopes_any {
|
||||||
|
let has_one = any.iter().any(|s| identity.scopes.iter().any(|i| i == s));
|
||||||
|
if !has_one {
|
||||||
|
return AccessResult::Forbidden(
|
||||||
|
"missing required scope (any of: ".to_string() + &any.join(", ") + ")",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(rt) = &self.resource_type {
|
||||||
|
let allowed = identity.resources.get(rt);
|
||||||
|
match &self.resource_action {
|
||||||
|
Some(action) => match allowed {
|
||||||
|
Some(actions) if actions.iter().any(|a| a == action) => {}
|
||||||
|
_ => {
|
||||||
|
return AccessResult::Forbidden(format!("missing resource: {rt}/{action}"))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => match allowed {
|
||||||
|
Some(actions) if !actions.is_empty() => {}
|
||||||
|
_ => return AccessResult::Forbidden(format!("missing resource: {rt}")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if let Some(action) = &self.resource_action {
|
||||||
|
let found = identity
|
||||||
|
.resources
|
||||||
|
.values()
|
||||||
|
.any(|actions| actions.iter().any(|a| a == action));
|
||||||
|
if !found {
|
||||||
|
return AccessResult::Forbidden(format!("missing resource action: {action}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessResult::Allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct OperationSpec {
|
||||||
|
pub name: String,
|
||||||
|
pub namespace: String,
|
||||||
|
pub op_type: OperationType,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub input_schema: Value,
|
||||||
|
pub output_schema: Value,
|
||||||
|
pub error_schemas: Vec<ErrorDefinition>,
|
||||||
|
pub access_control: AccessControl,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OperationSpec {
|
||||||
|
pub fn new(
|
||||||
|
name: impl Into<String>,
|
||||||
|
op_type: OperationType,
|
||||||
|
visibility: Visibility,
|
||||||
|
input_schema: Value,
|
||||||
|
output_schema: Value,
|
||||||
|
error_schemas: Vec<ErrorDefinition>,
|
||||||
|
access_control: AccessControl,
|
||||||
|
) -> Self {
|
||||||
|
let name = name.into();
|
||||||
|
let namespace = name
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
namespace,
|
||||||
|
op_type,
|
||||||
|
visibility,
|
||||||
|
input_schema,
|
||||||
|
output_schema,
|
||||||
|
error_schemas,
|
||||||
|
access_control,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> String {
|
||||||
|
format!("/{}", self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn identity(scopes: &[&str], resources: &[(&str, &[&str])]) -> Identity {
|
||||||
|
let mut res = HashMap::new();
|
||||||
|
for (k, v) in resources {
|
||||||
|
res.insert(
|
||||||
|
(*k).to_string(),
|
||||||
|
v.iter().map(|s| (*s).to_string()).collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Identity {
|
||||||
|
id: "caller".to_string(),
|
||||||
|
scopes: scopes.iter().map(|s| (*s).to_string()).collect(),
|
||||||
|
resources: res,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_has_leading_slash() {
|
||||||
|
let spec = OperationSpec::new(
|
||||||
|
"fs/readFile",
|
||||||
|
OperationType::Query,
|
||||||
|
Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
AccessControl::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(spec.path(), "/fs/readFile");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_derived_from_name() {
|
||||||
|
let spec = OperationSpec::new(
|
||||||
|
"agent/chat",
|
||||||
|
OperationType::Subscription,
|
||||||
|
Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
AccessControl::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(spec.namespace, "agent");
|
||||||
|
assert_eq!(spec.name, "agent/chat");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_for_single_segment() {
|
||||||
|
let spec = OperationSpec::new(
|
||||||
|
"list",
|
||||||
|
OperationType::Query,
|
||||||
|
Visibility::Internal,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
AccessControl::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(spec.namespace, "list");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_access_control_allowed_for_all() {
|
||||||
|
let acl = AccessControl::default();
|
||||||
|
assert_eq!(acl.check(None), AccessResult::Allowed);
|
||||||
|
let id = identity(&[], &[]);
|
||||||
|
assert_eq!(acl.check(Some(&id)), AccessResult::Allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn none_identity_with_restrictions_forbidden() {
|
||||||
|
let acl = AccessControl {
|
||||||
|
required_scopes: vec!["read".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
acl.check(None),
|
||||||
|
AccessResult::Forbidden("authentication required".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let acl2 = AccessControl {
|
||||||
|
required_scopes_any: Some(vec!["read".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
acl2.check(None),
|
||||||
|
AccessResult::Forbidden("authentication required".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let acl3 = AccessControl {
|
||||||
|
resource_type: Some("service".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
acl3.check(None),
|
||||||
|
AccessResult::Forbidden("authentication required".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_scopes_and_checked() {
|
||||||
|
let acl = AccessControl {
|
||||||
|
required_scopes: vec!["a".to_string(), "b".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let id_missing = identity(&["a"], &[]);
|
||||||
|
assert!(matches!(
|
||||||
|
acl.check(Some(&id_missing)),
|
||||||
|
AccessResult::Forbidden(_)
|
||||||
|
));
|
||||||
|
let id_ok = identity(&["a", "b", "c"], &[]);
|
||||||
|
assert_eq!(acl.check(Some(&id_ok)), AccessResult::Allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_scopes_any_or_checked() {
|
||||||
|
let acl = AccessControl {
|
||||||
|
required_scopes_any: Some(vec!["x".to_string(), "y".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let id_x = identity(&["x"], &[]);
|
||||||
|
assert_eq!(acl.check(Some(&id_x)), AccessResult::Allowed);
|
||||||
|
let id_y = identity(&["y"], &[]);
|
||||||
|
assert_eq!(acl.check(Some(&id_y)), AccessResult::Allowed);
|
||||||
|
let id_none = identity(&["z"], &[]);
|
||||||
|
assert!(matches!(
|
||||||
|
acl.check(Some(&id_none)),
|
||||||
|
AccessResult::Forbidden(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resource_check_with_type_and_action() {
|
||||||
|
let acl = AccessControl {
|
||||||
|
resource_type: Some("service".to_string()),
|
||||||
|
resource_action: Some("read".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let id_ok = identity(&[], &[("service", &["read"])]);
|
||||||
|
assert_eq!(acl.check(Some(&id_ok)), AccessResult::Allowed);
|
||||||
|
let id_missing_action = identity(&[], &[("service", &["write"])]);
|
||||||
|
assert!(matches!(
|
||||||
|
acl.check(Some(&id_missing_action)),
|
||||||
|
AccessResult::Forbidden(_)
|
||||||
|
));
|
||||||
|
let id_missing_type = identity(&[], &[("other", &["read"])]);
|
||||||
|
assert!(matches!(
|
||||||
|
acl.check(Some(&id_missing_type)),
|
||||||
|
AccessResult::Forbidden(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combined_scopes_and_resources() {
|
||||||
|
let acl = AccessControl {
|
||||||
|
required_scopes: vec!["admin".to_string()],
|
||||||
|
resource_type: Some("service".to_string()),
|
||||||
|
resource_action: Some("read".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let id_ok = identity(&["admin"], &[("service", &["read"])]);
|
||||||
|
assert_eq!(acl.check(Some(&id_ok)), AccessResult::Allowed);
|
||||||
|
let id_missing_scope = identity(&["user"], &[("service", &["read"])]);
|
||||||
|
assert!(matches!(
|
||||||
|
acl.check(Some(&id_missing_scope)),
|
||||||
|
AccessResult::Forbidden(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user