feat(call): wire dispatch_requested to resolve peer Identity, ACL gate, and forwarded_for (call/dispatch-peer-identity)
This commit is contained in:
@@ -180,7 +180,6 @@ impl Dispatcher {
|
|||||||
|
|
||||||
let connection_identity = connection.connection().identity().cloned();
|
let connection_identity = connection.connection().identity().cloned();
|
||||||
let identity = self.resolve_identity(connection_identity, &payload);
|
let identity = self.resolve_identity(connection_identity, &payload);
|
||||||
|
|
||||||
let forwarded_for = payload
|
let forwarded_for = payload
|
||||||
.get("forwarded_for")
|
.get("forwarded_for")
|
||||||
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
|
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
|
||||||
@@ -316,3 +315,390 @@ impl Clone for Dispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::registry::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||||
|
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||||
|
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||||
|
use alknet_core::types::{Capabilities, MockConnection};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
|
||||||
|
struct StubConnection {
|
||||||
|
alpn: &'static [u8],
|
||||||
|
addr: Option<SocketAddr>,
|
||||||
|
closed: StdMutex<Option<(u32, String)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockConnection for StubConnection {
|
||||||
|
fn remote_alpn(&self) -> &[u8] {
|
||||||
|
self.alpn
|
||||||
|
}
|
||||||
|
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||||
|
self.addr
|
||||||
|
}
|
||||||
|
fn close(&self, code: u32, reason: &str) {
|
||||||
|
*self.closed.lock().unwrap() = Some((code, reason.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stub_connection() -> alknet_core::types::Connection {
|
||||||
|
alknet_core::types::Connection::from_mock(Arc::new(StubConnection {
|
||||||
|
alpn: b"alknet/call",
|
||||||
|
addr: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
|
||||||
|
closed: StdMutex::new(None),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StaticIdentityProvider {
|
||||||
|
tokens: StdMutex<HashMap<String, Identity>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaticIdentityProvider {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tokens: StdMutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_token(self, token: &str, identity: Identity) -> Self {
|
||||||
|
self.tokens
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(token.to_string(), identity);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityProvider for StaticIdentityProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||||
|
let token_str = String::from_utf8_lossy(&token.raw);
|
||||||
|
self.tokens.lock().unwrap().get(token_str.as_ref()).cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity {
|
||||||
|
Identity {
|
||||||
|
id: id.to_string(),
|
||||||
|
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||||
|
resources: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn external_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||||
|
OperationSpec::new(
|
||||||
|
name,
|
||||||
|
OperationType::Query,
|
||||||
|
Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
acl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_spec(name: &str, acl: AccessControl) -> OperationSpec {
|
||||||
|
OperationSpec::new(
|
||||||
|
name,
|
||||||
|
OperationType::Query,
|
||||||
|
Visibility::Internal,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
acl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_with(name: &str, visibility: Visibility, acl: AccessControl) -> OperationRegistry {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
registry.register(HandlerRegistration::new(
|
||||||
|
OperationSpec::new(
|
||||||
|
name,
|
||||||
|
OperationType::Query,
|
||||||
|
visibility,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
acl,
|
||||||
|
),
|
||||||
|
make_handler(|input, context| async move {
|
||||||
|
ResponseEnvelope::ok(context.request_id, input)
|
||||||
|
}),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
));
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatcher() -> Dispatcher {
|
||||||
|
Dispatcher::new(
|
||||||
|
Arc::new(OperationRegistry::new()),
|
||||||
|
Arc::new(StaticIdentityProvider::new()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_authorized_peer_dispatches_and_populates_capabilities() {
|
||||||
|
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
let handler = make_handler(|_input, context| async move {
|
||||||
|
let has_google = context.capabilities.get("google").is_some();
|
||||||
|
ResponseEnvelope::ok(
|
||||||
|
context.request_id,
|
||||||
|
serde_json::json!({ "has_google": has_google }),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
registry.register(HandlerRegistration::new(
|
||||||
|
external_spec("admin/run", AccessControl::default()),
|
||||||
|
handler,
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
caps,
|
||||||
|
));
|
||||||
|
let registry = Arc::new(registry);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/admin/run",
|
||||||
|
"input": {},
|
||||||
|
});
|
||||||
|
let response = dp
|
||||||
|
.dispatch_requested(&conn, "req-1".to_string(), payload)
|
||||||
|
.await;
|
||||||
|
let out = response.result.expect("dispatch ok");
|
||||||
|
assert_eq!(out["has_google"], Value::Bool(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_unauthorized_peer_returns_forbidden_capabilities_never_populated() {
|
||||||
|
let caps = Capabilities::new().with_api_key("google", "k".to_string());
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
let handler = make_handler(|_input, context| async move {
|
||||||
|
let has_google = context.capabilities.get("google").is_some();
|
||||||
|
ResponseEnvelope::ok(
|
||||||
|
context.request_id,
|
||||||
|
serde_json::json!({ "has_google": has_google }),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
registry.register(HandlerRegistration::new(
|
||||||
|
external_spec(
|
||||||
|
"admin/run",
|
||||||
|
AccessControl {
|
||||||
|
required_scopes: vec!["admin".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
handler,
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
caps,
|
||||||
|
));
|
||||||
|
let registry = Arc::new(registry);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(
|
||||||
|
StaticIdentityProvider::new()
|
||||||
|
.with_token("alk_user", identity_with_scopes("regular-user", &["user"])),
|
||||||
|
);
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/admin/run",
|
||||||
|
"input": {},
|
||||||
|
"auth_token": "alk_user",
|
||||||
|
});
|
||||||
|
let response = dp
|
||||||
|
.dispatch_requested(&conn, "req-2".to_string(), payload)
|
||||||
|
.await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.code, "FORBIDDEN");
|
||||||
|
assert!(e.message.contains("admin"));
|
||||||
|
}
|
||||||
|
other => panic!("expected FORBIDDEN, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_internal_op_from_wire_returns_not_found_before_acl() {
|
||||||
|
let registry = Arc::new(registry_with(
|
||||||
|
"secret/op",
|
||||||
|
Visibility::Internal,
|
||||||
|
AccessControl::default(),
|
||||||
|
));
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/secret/op",
|
||||||
|
"input": {},
|
||||||
|
});
|
||||||
|
let response = dp
|
||||||
|
.dispatch_requested(&conn, "req-3".to_string(), payload)
|
||||||
|
.await;
|
||||||
|
match response.result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.code, "NOT_FOUND");
|
||||||
|
assert!(e.message.contains("secret/op"));
|
||||||
|
}
|
||||||
|
other => panic!("expected NOT_FOUND, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_connection_with_no_identity_produces_no_peer_id_in_env() {
|
||||||
|
let registry = Arc::new(registry_with(
|
||||||
|
"fs/readFile",
|
||||||
|
Visibility::External,
|
||||||
|
AccessControl::default(),
|
||||||
|
));
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = CallConnection::new(stub_connection());
|
||||||
|
|
||||||
|
let context = dp.build_root_context("req-4".to_string(), "fs/readFile", None, None, &conn);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
context.identity.is_none(),
|
||||||
|
"no connection identity → context.identity is None"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
context.env.peer_ids().is_empty(),
|
||||||
|
"no peer overlay attached when connection has no identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_connection_with_identity_attaches_peer_overlay_keyed_by_identity_id() {
|
||||||
|
let registry = Arc::new(registry_with(
|
||||||
|
"fs/readFile",
|
||||||
|
Visibility::External,
|
||||||
|
AccessControl::default(),
|
||||||
|
));
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = CallConnection::new(stub_connection());
|
||||||
|
conn.connection()
|
||||||
|
.set_identity(identity_with_scopes("worker-a", &[]))
|
||||||
|
.expect("identity not yet set");
|
||||||
|
|
||||||
|
let context = dp.build_root_context("req-5".to_string(), "fs/readFile", None, None, &conn);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
context.env.peer_ids(),
|
||||||
|
vec!["worker-a".to_string()],
|
||||||
|
"PeerId for connection comes from connection.identity().id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_extract_forwarded_for_from_payload_into_context() {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
let handler = make_handler(|_input, context| async move {
|
||||||
|
let forwarded_id = context.forwarded_for.as_ref().map(|i| i.id.clone());
|
||||||
|
ResponseEnvelope::ok(
|
||||||
|
context.request_id,
|
||||||
|
serde_json::json!({ "forwarded_for_id": forwarded_id }),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
registry.register(HandlerRegistration::new(
|
||||||
|
external_spec("fs/readFile", AccessControl::default()),
|
||||||
|
handler,
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
));
|
||||||
|
let registry = Arc::new(registry);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/fs/readFile",
|
||||||
|
"input": {},
|
||||||
|
"forwarded_for": {
|
||||||
|
"id": "alice",
|
||||||
|
"scopes": ["fs:read"],
|
||||||
|
"resources": {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let response = dp
|
||||||
|
.dispatch_requested(&conn, "req-6".to_string(), payload)
|
||||||
|
.await;
|
||||||
|
let out = response.result.expect("ok");
|
||||||
|
assert_eq!(out["forwarded_for_id"], Value::String("alice".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_without_forwarded_for_field_is_none() {
|
||||||
|
let mut registry = OperationRegistry::new();
|
||||||
|
let handler = make_handler(|_input, context| async move {
|
||||||
|
let present = context.forwarded_for.is_some();
|
||||||
|
ResponseEnvelope::ok(
|
||||||
|
context.request_id,
|
||||||
|
serde_json::json!({ "present": present }),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
registry.register(HandlerRegistration::new(
|
||||||
|
external_spec("fs/readFile", AccessControl::default()),
|
||||||
|
handler,
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Capabilities::new(),
|
||||||
|
));
|
||||||
|
let registry = Arc::new(registry);
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/fs/readFile",
|
||||||
|
"input": {},
|
||||||
|
});
|
||||||
|
let response = dp
|
||||||
|
.dispatch_requested(&conn, "req-7".to_string(), payload)
|
||||||
|
.await;
|
||||||
|
let out = response.result.expect("ok");
|
||||||
|
assert_eq!(out["present"], Value::Bool(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dispatch_default_access_control_dispatches_to_any_peer() {
|
||||||
|
let registry = Arc::new(registry_with(
|
||||||
|
"echo/run",
|
||||||
|
Visibility::External,
|
||||||
|
AccessControl::default(),
|
||||||
|
));
|
||||||
|
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
|
||||||
|
let dp = Dispatcher::new(registry, provider);
|
||||||
|
let conn = Arc::new(CallConnection::new(stub_connection()));
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"operationId": "/echo/run",
|
||||||
|
"input": { "msg": "hi" },
|
||||||
|
});
|
||||||
|
let response = dp
|
||||||
|
.dispatch_requested(&conn, "req-8".to_string(), payload)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.result, Ok(serde_json::json!({ "msg": "hi" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatcher_helper_compiles_with_full_signature() {
|
||||||
|
let _dp = dispatcher();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -287,6 +287,10 @@ impl OperationEnv for PeerCompositeEnv {
|
|||||||
.get(peer)
|
.get(peer)
|
||||||
.is_some_and(|c| c.contains(name))
|
.is_some_and(|c| c.contains(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn peer_ids(&self) -> Vec<PeerId> {
|
||||||
|
self.connection_order.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user