Merge branch 'feat/core/operation-context-registry'
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -87,6 +87,8 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-acme",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ssh-key",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -36,6 +36,8 @@ url = { version = "2", optional = true }
|
||||
async-trait = "0.1"
|
||||
ipnetwork = "0.21.1"
|
||||
arc-swap = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
alknet-core = { path = ".", features = ["testutil", "tls", "iroh"] }
|
||||
|
||||
58
crates/alknet-core/src/call/context.rs
Normal file
58
crates/alknet-core/src/call/context.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::call::OperationEnv;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OperationContext {
|
||||
pub request_id: String,
|
||||
pub parent_request_id: Option<String>,
|
||||
pub identity: Option<crate::auth::Identity>,
|
||||
pub metadata: HashMap<String, Value>,
|
||||
pub env: OperationEnv,
|
||||
pub trusted: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::call::OperationRegistry;
|
||||
|
||||
fn make_context() -> OperationContext {
|
||||
let registry = OperationRegistry::new();
|
||||
OperationContext {
|
||||
request_id: "req-1".to_string(),
|
||||
parent_request_id: None,
|
||||
identity: None,
|
||||
metadata: HashMap::new(),
|
||||
env: OperationEnv::local(registry),
|
||||
trusted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_context_fields() {
|
||||
let ctx = make_context();
|
||||
assert_eq!(ctx.request_id, "req-1");
|
||||
assert!(ctx.parent_request_id.is_none());
|
||||
assert!(ctx.identity.is_none());
|
||||
assert!(ctx.metadata.is_empty());
|
||||
assert!(!ctx.trusted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_context_with_parent() {
|
||||
let registry = OperationRegistry::new();
|
||||
let ctx = OperationContext {
|
||||
request_id: "req-2".to_string(),
|
||||
parent_request_id: Some("req-1".to_string()),
|
||||
identity: None,
|
||||
metadata: HashMap::new(),
|
||||
env: OperationEnv::local(registry),
|
||||
trusted: true,
|
||||
};
|
||||
assert_eq!(ctx.parent_request_id, Some("req-1".to_string()));
|
||||
assert!(ctx.trusted);
|
||||
}
|
||||
}
|
||||
100
crates/alknet-core/src/call/env.rs
Normal file
100
crates/alknet-core/src/call/env.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::call::context::OperationContext;
|
||||
use crate::call::registry::OperationRegistry;
|
||||
use crate::call::response::ResponseEnvelope;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OperationEnv {
|
||||
registry: Arc<OperationRegistry>,
|
||||
}
|
||||
|
||||
impl OperationEnv {
|
||||
pub fn local(registry: OperationRegistry) -> Self {
|
||||
Self {
|
||||
registry: Arc::new(registry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invoke(&self, namespace: &str, operation: &str, input: Value) -> ResponseEnvelope {
|
||||
let name = format!("{namespace}/{operation}");
|
||||
let request_id = format!("env-{name}");
|
||||
let context = OperationContext {
|
||||
request_id: request_id.clone(),
|
||||
parent_request_id: None,
|
||||
identity: None,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
env: self.clone(),
|
||||
trusted: true,
|
||||
};
|
||||
self.registry.invoke(&name, input, context)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::call::registry::OperationRegistryBuilder;
|
||||
use crate::call::spec::{AccessControl, OperationSpec, OperationType};
|
||||
|
||||
fn make_spec(name: &str, namespace: &str) -> OperationSpec {
|
||||
OperationSpec {
|
||||
name: name.to_string(),
|
||||
namespace: namespace.to_string(),
|
||||
op_type: OperationType::Query,
|
||||
input_schema: serde_json::json!({}),
|
||||
output_schema: serde_json::json!({}),
|
||||
access_control: AccessControl {
|
||||
required_scopes: vec![],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_env_local_invoke() {
|
||||
let registry = OperationRegistryBuilder::new()
|
||||
.with(
|
||||
make_spec("auth/verify", "auth"),
|
||||
Arc::new(|_input, _ctx| {
|
||||
ResponseEnvelope::ok("env-auth/verify", serde_json::json!({"verified": true}))
|
||||
}),
|
||||
)
|
||||
.build();
|
||||
|
||||
let env = OperationEnv::local(registry);
|
||||
let result = env.invoke("auth", "verify", serde_json::json!({"token": "abc"}));
|
||||
assert!(result.result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_env_invoke_missing() {
|
||||
let registry = OperationRegistry::new();
|
||||
let env = OperationEnv::local(registry);
|
||||
let result = env.invoke("auth", "verify", serde_json::json!(null));
|
||||
assert!(result.result.is_err());
|
||||
let err = result.result.unwrap_err();
|
||||
assert_eq!(err.code, "NOT_FOUND");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_env_invoke_trusted() {
|
||||
let registry = OperationRegistryBuilder::new()
|
||||
.with(
|
||||
make_spec("auth/verify", "auth"),
|
||||
Arc::new(|_input, ctx| {
|
||||
assert!(ctx.trusted);
|
||||
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!({"ok": true}))
|
||||
}),
|
||||
)
|
||||
.build();
|
||||
|
||||
let env = OperationEnv::local(registry);
|
||||
let result = env.invoke("auth", "verify", serde_json::json!(null));
|
||||
assert!(result.result.is_ok());
|
||||
}
|
||||
}
|
||||
11
crates/alknet-core/src/call/mod.rs
Normal file
11
crates/alknet-core/src/call/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod context;
|
||||
pub mod env;
|
||||
pub mod registry;
|
||||
pub mod response;
|
||||
pub mod spec;
|
||||
|
||||
pub use context::OperationContext;
|
||||
pub use env::OperationEnv;
|
||||
pub use registry::{Handler, OperationRegistry, OperationRegistryBuilder};
|
||||
pub use response::{CallError, ResponseEnvelope};
|
||||
pub use spec::{AccessControl, OperationSpec, OperationType};
|
||||
337
crates/alknet-core/src/call/registry.rs
Normal file
337
crates/alknet-core/src/call/registry.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::call::context::OperationContext;
|
||||
use crate::call::response::ResponseEnvelope;
|
||||
use crate::call::spec::OperationSpec;
|
||||
|
||||
pub type Handler = Arc<dyn Fn(Value, OperationContext) -> ResponseEnvelope + Send + Sync>;
|
||||
|
||||
pub struct OperationRegistry {
|
||||
operations: HashMap<String, (OperationSpec, Handler)>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OperationRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OperationRegistry")
|
||||
.field("operation_count", &self.operations.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl OperationRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
operations: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, spec: OperationSpec, handler: Handler) {
|
||||
self.operations.insert(spec.name.clone(), (spec, handler));
|
||||
}
|
||||
|
||||
pub fn lookup(&self, name: &str) -> Option<(&OperationSpec, &Handler)> {
|
||||
self.operations
|
||||
.get(name)
|
||||
.map(|(spec, handler)| (spec, handler))
|
||||
}
|
||||
|
||||
pub fn invoke(&self, name: &str, input: Value, context: OperationContext) -> ResponseEnvelope {
|
||||
match self.lookup(name) {
|
||||
Some((spec, handler)) => {
|
||||
if !context.trusted {
|
||||
if let Some(ref identity) = context.identity {
|
||||
if !spec.access_control.check(identity) {
|
||||
return ResponseEnvelope::err(
|
||||
&context.request_id,
|
||||
"FORBIDDEN",
|
||||
"access denied",
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else if spec.access_control.has_restrictions() {
|
||||
return ResponseEnvelope::err(
|
||||
&context.request_id,
|
||||
"FORBIDDEN",
|
||||
"authentication required",
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
handler(input, context)
|
||||
}
|
||||
None => ResponseEnvelope::err(
|
||||
&context.request_id,
|
||||
"NOT_FOUND",
|
||||
format!("operation not found: {name}"),
|
||||
false,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_operations(&self) -> Vec<&OperationSpec> {
|
||||
self.operations.values().map(|(spec, _)| spec).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OperationRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OperationRegistryBuilder {
|
||||
registry: OperationRegistry,
|
||||
}
|
||||
|
||||
impl OperationRegistryBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
registry: OperationRegistry::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with(mut self, spec: OperationSpec, handler: Handler) -> Self {
|
||||
self.registry.register(spec, handler);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> OperationRegistry {
|
||||
self.registry
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OperationRegistryBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::Identity;
|
||||
use crate::call::env::OperationEnv;
|
||||
use crate::call::spec::{AccessControl, OperationType};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_spec(name: &str, namespace: &str) -> OperationSpec {
|
||||
OperationSpec {
|
||||
name: name.to_string(),
|
||||
namespace: namespace.to_string(),
|
||||
op_type: OperationType::Query,
|
||||
input_schema: serde_json::json!({}),
|
||||
output_schema: serde_json::json!({}),
|
||||
access_control: AccessControl {
|
||||
required_scopes: vec![],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_spec_with_acl(name: &str, namespace: &str, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec {
|
||||
name: name.to_string(),
|
||||
namespace: namespace.to_string(),
|
||||
op_type: OperationType::Mutation,
|
||||
input_schema: serde_json::json!({}),
|
||||
output_schema: serde_json::json!({}),
|
||||
access_control: acl,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_context(request_id: &str, identity: Option<Identity>) -> OperationContext {
|
||||
let registry = OperationRegistry::new();
|
||||
OperationContext {
|
||||
request_id: request_id.to_string(),
|
||||
parent_request_id: None,
|
||||
identity,
|
||||
metadata: HashMap::new(),
|
||||
env: OperationEnv::local(registry),
|
||||
trusted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_lookup() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let spec = make_spec("fs/readFile", "fs");
|
||||
let handler: Handler = Arc::new(|input, _ctx| ResponseEnvelope::ok("req-1", input));
|
||||
registry.register(spec, handler);
|
||||
let found = registry.lookup("fs/readFile");
|
||||
assert!(found.is_some());
|
||||
let (spec, _) = found.unwrap();
|
||||
assert_eq!(spec.name, "fs/readFile");
|
||||
assert_eq!(spec.namespace, "fs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_missing_returns_none() {
|
||||
let registry = OperationRegistry::new();
|
||||
assert!(registry.lookup("missing").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_operation() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let spec = make_spec("fs/readFile", "fs");
|
||||
let handler: Handler = Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input));
|
||||
registry.register(spec, handler);
|
||||
let context = make_context("req-1", None);
|
||||
let result = registry.invoke("fs/readFile", serde_json::json!({"path": "/tmp"}), context);
|
||||
assert!(result.result.is_ok());
|
||||
assert_eq!(result.result.unwrap(), serde_json::json!({"path": "/tmp"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_missing_operation() {
|
||||
let registry = OperationRegistry::new();
|
||||
let context = make_context("req-1", None);
|
||||
let result = registry.invoke("missing", serde_json::json!(null), context);
|
||||
assert!(result.result.is_err());
|
||||
let err = result.result.unwrap_err();
|
||||
assert_eq!(err.code, "NOT_FOUND");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_with_acl_check_allowed() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let acl = AccessControl {
|
||||
required_scopes: vec!["read".to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
};
|
||||
let spec = make_spec_with_acl("bash/exec", "bash", acl);
|
||||
let handler: Handler = Arc::new(|_input, ctx| {
|
||||
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done"))
|
||||
});
|
||||
registry.register(spec, handler);
|
||||
|
||||
let identity = Identity {
|
||||
id: "user-1".to_string(),
|
||||
scopes: vec!["read".to_string()],
|
||||
resources: HashMap::new(),
|
||||
};
|
||||
let context = make_context("req-1", Some(identity));
|
||||
let result = registry.invoke("bash/exec", serde_json::json!(null), context);
|
||||
assert!(result.result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_with_acl_check_denied() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let acl = AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
};
|
||||
let spec = make_spec_with_acl("bash/exec", "bash", acl);
|
||||
let handler: Handler = Arc::new(|_input, ctx| {
|
||||
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done"))
|
||||
});
|
||||
registry.register(spec, handler);
|
||||
|
||||
let identity = Identity {
|
||||
id: "user-1".to_string(),
|
||||
scopes: vec!["read".to_string()],
|
||||
resources: HashMap::new(),
|
||||
};
|
||||
let context = make_context("req-1", Some(identity));
|
||||
let result = registry.invoke("bash/exec", serde_json::json!(null), context);
|
||||
assert!(result.result.is_err());
|
||||
let err = result.result.unwrap_err();
|
||||
assert_eq!(err.code, "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_trusted_skips_acl() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let acl = AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
};
|
||||
let spec = make_spec_with_acl("bash/exec", "bash", acl);
|
||||
let handler: Handler = Arc::new(|_input, ctx| {
|
||||
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done"))
|
||||
});
|
||||
registry.register(spec, handler);
|
||||
|
||||
let identity = Identity {
|
||||
id: "user-1".to_string(),
|
||||
scopes: vec!["read".to_string()],
|
||||
resources: HashMap::new(),
|
||||
};
|
||||
let mut registry2 = OperationRegistry::new();
|
||||
let context = OperationContext {
|
||||
request_id: "req-1".to_string(),
|
||||
parent_request_id: None,
|
||||
identity: Some(identity),
|
||||
metadata: HashMap::new(),
|
||||
env: OperationEnv::local(registry2),
|
||||
trusted: true,
|
||||
};
|
||||
let result = registry.invoke("bash/exec", serde_json::json!(null), context);
|
||||
assert!(result.result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_no_identity_with_acl_denied() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
let acl = AccessControl {
|
||||
required_scopes: vec!["read".to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
};
|
||||
let spec = make_spec_with_acl("bash/exec", "bash", acl);
|
||||
let handler: Handler = Arc::new(|_input, ctx| {
|
||||
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done"))
|
||||
});
|
||||
registry.register(spec, handler);
|
||||
|
||||
let context = make_context("req-1", None);
|
||||
let result = registry.invoke("bash/exec", serde_json::json!(null), context);
|
||||
assert!(result.result.is_err());
|
||||
let err = result.result.unwrap_err();
|
||||
assert_eq!(err.code, "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_operations() {
|
||||
let mut registry = OperationRegistry::new();
|
||||
registry.register(
|
||||
make_spec("fs/readFile", "fs"),
|
||||
Arc::new(|_, ctx| ResponseEnvelope::ok(&ctx.request_id, serde_json::json!(null))),
|
||||
);
|
||||
registry.register(
|
||||
make_spec("bash/exec", "bash"),
|
||||
Arc::new(|_, ctx| ResponseEnvelope::ok(&ctx.request_id, serde_json::json!(null))),
|
||||
);
|
||||
let ops = registry.list_operations();
|
||||
assert_eq!(ops.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_builder() {
|
||||
let registry = OperationRegistryBuilder::new()
|
||||
.with(
|
||||
make_spec("fs/readFile", "fs"),
|
||||
Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input)),
|
||||
)
|
||||
.with(
|
||||
make_spec("bash/exec", "bash"),
|
||||
Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input)),
|
||||
)
|
||||
.build();
|
||||
assert!(registry.lookup("fs/readFile").is_some());
|
||||
assert!(registry.lookup("bash/exec").is_some());
|
||||
}
|
||||
}
|
||||
97
crates/alknet-core/src/call/response.rs
Normal file
97
crates/alknet-core/src/call/response.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CallError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub retryable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResponseEnvelope {
|
||||
pub request_id: String,
|
||||
pub result: Result<Value, CallError>,
|
||||
}
|
||||
|
||||
impl ResponseEnvelope {
|
||||
pub fn ok(request_id: impl Into<String>, value: Value) -> Self {
|
||||
Self {
|
||||
request_id: request_id.into(),
|
||||
result: Ok(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err(
|
||||
request_id: impl Into<String>,
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
retryable: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
request_id: request_id.into(),
|
||||
result: Err(CallError {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
retryable,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn call_error_fields() {
|
||||
let err = CallError {
|
||||
code: "NOT_FOUND".to_string(),
|
||||
message: "operation not found".to_string(),
|
||||
retryable: false,
|
||||
};
|
||||
assert_eq!(err.code, "NOT_FOUND");
|
||||
assert_eq!(err.message, "operation not found");
|
||||
assert!(!err.retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_envelope_ok() {
|
||||
let env = ResponseEnvelope::ok("req-1", json!({"status": "ok"}));
|
||||
assert_eq!(env.request_id, "req-1");
|
||||
assert!(env.result.is_ok());
|
||||
assert_eq!(env.result.unwrap(), json!({"status": "ok"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_envelope_err() {
|
||||
let env = ResponseEnvelope::err("req-1", "NOT_FOUND", "operation not found", false);
|
||||
assert_eq!(env.request_id, "req-1");
|
||||
assert!(env.result.is_err());
|
||||
let err = env.result.unwrap_err();
|
||||
assert_eq!(err.code, "NOT_FOUND");
|
||||
assert_eq!(err.message, "operation not found");
|
||||
assert!(!err.retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_envelope_serialization() {
|
||||
let env = ResponseEnvelope::ok("req-1", json!({"key": "value"}));
|
||||
let serialized = serde_json::to_string(&env).unwrap();
|
||||
let deserialized: ResponseEnvelope = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(deserialized.request_id, "req-1");
|
||||
assert!(deserialized.result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_envelope_err_serialization() {
|
||||
let env = ResponseEnvelope::err("req-2", "TIMEOUT", "timed out", true);
|
||||
let serialized = serde_json::to_string(&env).unwrap();
|
||||
let deserialized: ResponseEnvelope = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(deserialized.request_id, "req-2");
|
||||
let err = deserialized.result.unwrap_err();
|
||||
assert_eq!(err.code, "TIMEOUT");
|
||||
assert!(err.retryable);
|
||||
}
|
||||
}
|
||||
233
crates/alknet-core/src/call/spec.rs
Normal file
233
crates/alknet-core/src/call/spec.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum OperationType {
|
||||
Query,
|
||||
Mutation,
|
||||
Subscription,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
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>,
|
||||
}
|
||||
|
||||
impl AccessControl {
|
||||
pub fn check(&self, identity: &crate::auth::Identity) -> bool {
|
||||
for scope in &self.required_scopes {
|
||||
if !identity.scopes.contains(scope) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(any) = &self.required_scopes_any {
|
||||
if !any.iter().any(|s| identity.scopes.contains(s)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(res_type) = &self.resource_type {
|
||||
if let Some(actions) = identity.resources.get(res_type) {
|
||||
if let Some(action) = &self.resource_action {
|
||||
if !actions.contains(action) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn has_restrictions(&self) -> bool {
|
||||
!self.required_scopes.is_empty()
|
||||
|| self.required_scopes_any.is_some()
|
||||
|| self.resource_type.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OperationSpec {
|
||||
pub name: String,
|
||||
pub namespace: String,
|
||||
pub op_type: OperationType,
|
||||
pub input_schema: Value,
|
||||
pub output_schema: Value,
|
||||
pub access_control: AccessControl,
|
||||
}
|
||||
|
||||
impl OperationSpec {
|
||||
pub fn path(&self) -> String {
|
||||
format!("/{}", self.name)
|
||||
}
|
||||
|
||||
pub fn namespace_from_name(name: &str) -> String {
|
||||
let trimmed = name.trim_start_matches('/');
|
||||
let parts: Vec<&str> = trimmed.split('/').collect();
|
||||
match parts.len() {
|
||||
n if n >= 3 => parts[1].to_string(),
|
||||
n if n >= 2 => parts[0].to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_identity(
|
||||
scopes: Vec<String>,
|
||||
resources: HashMap<String, Vec<String>>,
|
||||
) -> crate::auth::Identity {
|
||||
crate::auth::Identity {
|
||||
id: "test".to_string(),
|
||||
scopes,
|
||||
resources,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_control_allows_matching_scopes() {
|
||||
let ac = AccessControl {
|
||||
required_scopes: vec!["read".to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
};
|
||||
let id = make_identity(vec!["read".to_string()], HashMap::new());
|
||||
assert!(ac.check(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_control_rejects_missing_scopes() {
|
||||
let ac = AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
};
|
||||
let id = make_identity(vec!["read".to_string()], HashMap::new());
|
||||
assert!(!ac.check(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_control_required_scopes_any_matches() {
|
||||
let ac = AccessControl {
|
||||
required_scopes: vec![],
|
||||
required_scopes_any: Some(vec!["admin".to_string(), "read".to_string()]),
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
};
|
||||
let id = make_identity(vec!["read".to_string()], HashMap::new());
|
||||
assert!(ac.check(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_control_required_scopes_any_rejects() {
|
||||
let ac = AccessControl {
|
||||
required_scopes: vec![],
|
||||
required_scopes_any: Some(vec!["admin".to_string()]),
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
};
|
||||
let id = make_identity(vec!["read".to_string()], HashMap::new());
|
||||
assert!(!ac.check(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_control_resource_check_matches() {
|
||||
let mut resources = HashMap::new();
|
||||
resources.insert("service".to_string(), vec!["read".to_string()]);
|
||||
let ac = AccessControl {
|
||||
required_scopes: vec![],
|
||||
required_scopes_any: None,
|
||||
resource_type: Some("service".to_string()),
|
||||
resource_action: Some("read".to_string()),
|
||||
};
|
||||
let id = make_identity(vec![], resources);
|
||||
assert!(ac.check(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_control_resource_check_missing_resource_type() {
|
||||
let ac = AccessControl {
|
||||
required_scopes: vec![],
|
||||
required_scopes_any: None,
|
||||
resource_type: Some("service".to_string()),
|
||||
resource_action: Some("read".to_string()),
|
||||
};
|
||||
let id = make_identity(vec![], HashMap::new());
|
||||
assert!(!ac.check(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_control_resource_check_missing_action() {
|
||||
let mut resources = HashMap::new();
|
||||
resources.insert("service".to_string(), vec!["write".to_string()]);
|
||||
let ac = AccessControl {
|
||||
required_scopes: vec![],
|
||||
required_scopes_any: None,
|
||||
resource_type: Some("service".to_string()),
|
||||
resource_action: Some("read".to_string()),
|
||||
};
|
||||
let id = make_identity(vec![], resources);
|
||||
assert!(!ac.check(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_control_combined_scopes_and_resources() {
|
||||
let mut resources = HashMap::new();
|
||||
resources.insert("service".to_string(), vec!["read".to_string()]);
|
||||
let ac = AccessControl {
|
||||
required_scopes: vec!["relay:connect".to_string()],
|
||||
required_scopes_any: Some(vec!["admin".to_string()]),
|
||||
resource_type: Some("service".to_string()),
|
||||
resource_action: Some("read".to_string()),
|
||||
};
|
||||
let id = make_identity(
|
||||
vec!["relay:connect".to_string(), "admin".to_string()],
|
||||
resources,
|
||||
);
|
||||
assert!(ac.check(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_type_variants() {
|
||||
assert_eq!(OperationType::Query, OperationType::Query);
|
||||
assert_ne!(OperationType::Query, OperationType::Mutation);
|
||||
assert_ne!(OperationType::Mutation, OperationType::Subscription);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_spec_namespace_from_name() {
|
||||
assert_eq!(OperationSpec::namespace_from_name("/auth/verify"), "auth");
|
||||
assert_eq!(OperationSpec::namespace_from_name("/fs/readFile"), "fs");
|
||||
assert_eq!(
|
||||
OperationSpec::namespace_from_name("/head/agent/chat"),
|
||||
"agent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_spec_path() {
|
||||
let spec = OperationSpec {
|
||||
name: "auth/verify".to_string(),
|
||||
namespace: "auth".to_string(),
|
||||
op_type: OperationType::Query,
|
||||
input_schema: serde_json::json!({}),
|
||||
output_schema: serde_json::json!({}),
|
||||
access_control: AccessControl {
|
||||
required_scopes: vec![],
|
||||
required_scopes_any: None,
|
||||
resource_type: None,
|
||||
resource_action: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(spec.path(), "/auth/verify");
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod call;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
@@ -65,6 +66,10 @@ pub mod testutil;
|
||||
#[cfg(feature = "irpc")]
|
||||
pub use auth::{AuthProtocol, AuthResult, AuthServiceImpl};
|
||||
pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
|
||||
pub use call::{
|
||||
AccessControl, CallError, Handler, OperationContext, OperationEnv, OperationRegistry,
|
||||
OperationRegistryBuilder, OperationSpec, OperationType, ResponseEnvelope,
|
||||
};
|
||||
pub use client::channel_manager::{ChannelManager, ForwardRequest};
|
||||
pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};
|
||||
pub use config::{
|
||||
|
||||
Reference in New Issue
Block a user