Merge branch 'feat/core/operation-context-registry'

This commit is contained in:
2026-06-07 14:44:38 +00:00
9 changed files with 845 additions and 0 deletions

View File

@@ -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"] }

View 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);
}
}

View 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());
}
}

View 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};

View 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());
}
}

View 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);
}
}

View 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");
}
}

View File

@@ -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::{