Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 141 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions _context/wiki/mcp-capability-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ authorization context.
dataplane, and integration tests must define together. The current coarse
`sub`-only implementation is not the Phase 3 target.

### Stateless Task Handles

Modern Tasks lifecycle calls are targeted operations under the same
authorization invariants. The dataplane exposes an encrypted, stateless handle
rather than an upstream task ID or process-local mapping. The handle binds the
upstream ID and route to a trusted authorization-context ID, virtual server,
configuration revision, immutable backend ID, and backend generation.

Every `tasks/get`, `tasks/update`, and `tasks/cancel` request independently
derives its authorization context from verified claims and the validated route,
loads the current effective configuration, enforces method scope and compiled
policy, and accepts the handle only when its backend ID still resolves to the
same generation. A mismatch returns the same invalid-task error as malformed
input and makes no upstream call. Dataplane replicas share the handle key so
decoding does not require Redis task state or session affinity.

The codec is a prerequisite only; task creation and lifecycle proxy handlers
remain separate implementation work and are not current routing behavior.

## MCP Work Allocation

| Work | Target owner and behavior |
Expand Down
13 changes: 13 additions & 0 deletions _context/wiki/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ The gateway wraps per-backend cursors inside its own opaque token (JSON, treated

**Known limitation:** if backend set changes between pages, removed backend's cursor is silently dropped.

## Task-Handle Codec (not wired)

The library contains the codec prerequisite for modern Tasks, but no current
handler emits task handles or proxies `tasks/get`, `tasks/update`, or
`tasks/cancel` yet.

- Never expose or log an upstream task ID directly; decoded-route debug output redacts it.
- Encode it as `cfth1.<base64url>` with misuse-resistant AES-256-GCM-SIV and a random nonce.
- Bind the payload to the trusted authorization-context ID, virtual host, configuration revision, backend ID, and backend generation.
- On decode, independently derive the current authorization scope and accept the backend only when the effective configuration resolves the same ID and generation.
- Return every scope, revision, route, malformed-input, and version mismatch as `invalid task ID` without an upstream call.
- Handles are stateless. Replicas must share the key; key rotation invalidates outstanding handles.

## Session State (local process)

Backend RMCP services are stored in `BackendTransports` keyed by:
Expand Down
4 changes: 4 additions & 0 deletions _context/wiki/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dataplane contract.
| If this is compromised | Impact |
| --- | --- |
| JWT signing key or HMAC secret | Attacker mints tokens for any subject and reaches that subject's backends. Rotate the key and restart; no revocation exists. |
| Task-handle key | Attacker decrypts or forges upstream task routes. Rotate the key; outstanding handles become invalid. |
| Redis write access | Attacker rewrites routing (arbitrary backend URLs receive caller traffic) and, if runtime plugins are enabled, chooses which registered hooks run on payloads. Protect Redis with TLS/mTLS and control-plane-only write access. |
| A backend MCP server | Attacker sees requests routed to that backend and controls its responses; the namespace prefix limits blast radius to that backend's objects. |
| The gateway process | Full compromise: it holds the decoding keys in memory and live backend sessions. |
Expand Down Expand Up @@ -102,4 +103,7 @@ These routes are registered **outside the authentication middleware** — unauth
## Secrets Handling

- The HMAC secret is held as a `SecretString`; key and certificate material is read from disk paths at startup.
- The not-yet-wired task-handle codec encrypts upstream task IDs and binds them to a trusted authorization-context ID, virtual host, configuration revision, backend ID, and backend generation.
- Task handles do not replace per-request JWT validation, effective-configuration lookup, scope/RBAC checks, or current backend-generation validation.
- Never log: tokens, authorization headers, secrets, Redis key/value bytes, full `UserConfig` documents, or backend credentials.
- Treat task handles and decoded upstream task IDs as opaque secrets; do not log them. Decoded-route debug output must remain redacted.
3 changes: 3 additions & 0 deletions crates/contextforge-data-plane-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ tokio-rustls = "0.26.4"
typed-builder = "0.23.2"
url = { workspace = true, features = ["serde"] }
secret-string = "0.0.2"
base64 = "0.22.1"
aes-gcm-siv = { version = "0.12.0", features = ["zeroize"] }
zeroize = "1.9.0"


[features]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl<'a> AuthorizedCallValidator<'a> {
let has_user_config = maybe_user_config.is_some();
let virtual_hosts = maybe_user_config.map_or(0, |user_config| user_config.virtual_hosts.len());
let has_claims = maybe_claims.is_some();
let virtual_host_id = maybe_virtual_host_id.map_or("<missing>", |id| id.value().as_str());
let virtual_host_id = maybe_virtual_host_id.map_or("<missing>", VirtualHostId::as_str);
debug!(
"AuthorizedCallValidator::validate - mcp call validation call_name = {call_name} has_user_config = {has_user_config} virtual_hosts = {virtual_hosts} has_claims = {has_claims} virtual_host_id = {virtual_host_id}"
);
Expand All @@ -48,9 +48,9 @@ impl<'a> AuthorizedCallValidator<'a> {
});
};

let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.value()) else {
let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.as_str()) else {
let call_name = self.call_name;
let virtual_host_id = virtual_host_id.value();
let virtual_host_id = virtual_host_id.as_str();
let virtual_hosts = user_config.virtual_hosts.len();
debug!(
"AuthorizedCallValidator::validate - mcp virtual host config missing call_name = {call_name} virtual_host_id = {virtual_host_id} virtual_hosts = {virtual_hosts}"
Expand Down Expand Up @@ -85,7 +85,7 @@ impl<'a> AuthorizedCallValidator<'a> {
let virtual_hosts = maybe_user_config.map_or(0, |user_config| user_config.virtual_hosts.len());
let has_session_id = maybe_session_id.is_some();
let has_claims = maybe_claims.is_some();
let virtual_host_id = maybe_virtual_host_id.map_or("<missing>", |id| id.value().as_str());
let virtual_host_id = maybe_virtual_host_id.map_or("<missing>", VirtualHostId::as_str);
debug!(
"AuthorizedCallValidator::validate - mcp call validation call_name = {call_name} has_user_config = {has_user_config} virtual_hosts = {virtual_hosts} has_session_id = {has_session_id} has_claims = {has_claims} virtual_host_id = {virtual_host_id}"
);
Expand Down Expand Up @@ -114,9 +114,9 @@ impl<'a> AuthorizedCallValidator<'a> {
});
};

let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.value()) else {
let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.as_str()) else {
let call_name = self.call_name;
let virtual_host_id = virtual_host_id.value();
let virtual_host_id = virtual_host_id.as_str();
let virtual_hosts = user_config.virtual_hosts.len();
debug!(
"AuthorizedCallValidator::validate - mcp virtual host config missing call_name = {call_name} virtual_host_id = {virtual_host_id} virtual_hosts = {virtual_hosts}"
Expand Down Expand Up @@ -160,7 +160,7 @@ impl<'a> InitializeCallValidator<'a> {
let virtual_hosts = maybe_user_config.map_or(0, |user_config| user_config.virtual_hosts.len());
let has_session_id = true;
let has_claims = maybe_claims.is_some();
let virtual_host_id = maybe_virtual_host_id.map_or("<missing>", |id| id.value().as_str());
let virtual_host_id = maybe_virtual_host_id.map_or("<missing>", VirtualHostId::as_str);
debug!(
"InitializeCallValidator::validate - mcp call validation call_name = {call_name} has_user_config = {has_user_config} virtual_hosts = {virtual_hosts} has_session_id = {has_session_id} has_claims = {has_claims} virtual_host_id = {virtual_host_id}"
);
Expand All @@ -181,9 +181,9 @@ impl<'a> InitializeCallValidator<'a> {
});
};

let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.value()) else {
let Some(virtual_host) = user_config.virtual_hosts.get(virtual_host_id.as_str()) else {
let call_name = "initialize";
let virtual_host_id = virtual_host_id.value();
let virtual_host_id = virtual_host_id.as_str();
let virtual_hosts = user_config.virtual_hosts.len();
debug!(
"InitializeCallValidator::validate - mcp virtual host config missing call_name = {call_name} virtual_host_id = {virtual_host_id} virtual_hosts = {virtual_hosts}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub async fn virtual_host_config_layer(request: http::Request<axum::body::Body>,
if let (Some(virtual_host_id), Some(user_config)) = (virtual_host_id, user_config)
&& !has_virtual_host(user_config, virtual_host_id)
{
let virtual_host_id = virtual_host_id.value();
let virtual_host_id = virtual_host_id.as_str();
let virtual_hosts = user_config.virtual_hosts.len();
debug!(
"virtual_host_config_layer - virtual host config missing virtual_host_id = {virtual_host_id} virtual_hosts = {virtual_hosts}"
Expand All @@ -26,7 +26,7 @@ pub async fn virtual_host_config_layer(request: http::Request<axum::body::Body>,
}

fn has_virtual_host(user_config: &UserConfig, virtual_host_id: &VirtualHostId) -> bool {
user_config.virtual_hosts.contains_key(virtual_host_id.value())
user_config.virtual_hosts.contains_key(virtual_host_id.as_str())
}

fn server_not_found_response() -> Response {
Expand Down
Loading