Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

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

13 changes: 13 additions & 0 deletions crates/graphql-orm-router/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ supersedes: []

# Changelog

## 0.5.1 - 2026-09-02

- Replaced the full duplicate of WebSocket subscription variables with a
bounded scalar-only authorization projection. Large data variables remain
single-copy, so operations such as chunked uploads stay within the private
transport's message limit.
- Preserved variable-dependent subscription authorization before subgraph work.
Client-supplied reserved metadata is replaced, and any authorization value
omitted by the projection or remaining-frame budget fails closed.

No router configuration, GraphQL schema, descriptor, token, or stored-data
migration is required.

## 0.5.0 - 2026-08-24

- Aligned the optional adapter to generic `agql-auth` 0.19.0 at reviewed merged
Expand Down
2 changes: 1 addition & 1 deletion crates/graphql-orm-router/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "graphql-orm-router"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
rust-version = "1.90"
description = "Federated GraphQL router for graphql-orm and project-neutral subgraphs"
Expand Down
10 changes: 10 additions & 0 deletions crates/graphql-orm-router/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ supersedes: []

# graphql-orm-router migration guide

## 0.5.0 to 0.5.1

No configuration, schema, token, descriptor, or stored-data migration is
required. Rebuild consumers at the reviewed 0.5.1 monorepo revision and run
their authenticated WebSocket acceptance tests. The router now sends only a
bounded scalar authorization projection beside the ordinary variables object;
large data variables are no longer duplicated on the private subscription
transport. Variable-dependent authorization still runs before subgraph work
and fails closed when a required value cannot fit that projection.

## 0.4.0 to 0.5.0

Align direct `agql-auth` consumers to 0.19.0 at reviewed merged revision
Expand Down
9 changes: 8 additions & 1 deletion crates/graphql-orm-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This unpublished package is Git-only:

```toml
[dependencies]
graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.5.0" }
graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.5.1" }
```

Enable `auth-agql` only when adapting a separately configured
Expand Down Expand Up @@ -90,6 +90,13 @@ access, and stale JWKS cache use explicit deny-by-default policy. Static source
configuration is deployment-owned; dynamic destinations are additionally
subject to DNS, host, port, CIDR, redirect, and peer validation.

Authenticated subscriptions forward the ordinary variables object once. A
separate private authorization extension contains only a bounded projection of
small scalar variables; client-supplied values for that reserved extension are
always replaced. Large data variables are not duplicated across the private
transport, and a variable-dependent authorization decision fails closed if its
value cannot fit the projection or remaining frame budget.

## Errors and operations

Public errors are router-owned and sanitized: status does not reveal SDL,
Expand Down
8 changes: 5 additions & 3 deletions crates/graphql-orm-router/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,9 +625,11 @@ impl RouterPlugin for StaticGraphPlugin {
let principal = payload.context.get_ref::<AuthenticatedPrincipal>();
// Hive has already moved supplied values into its coerced-variable
// payload at this hook. The public WebSocket gateway therefore copies
// each operation's raw values into a reserved extension before it
// enters Hive. Only the authenticated private endpoint may activate
// that extension; ordinary HTTP clients cannot spoof it.
// a bounded projection of authorization-capable scalar values into a
// reserved extension before it enters Hive. Only the authenticated
// private endpoint may activate that extension; ordinary HTTP clients
// cannot spoof it. Values omitted by the bound fail closed under
// complete variable resolution.
let trusted_subscription = payload
.context
.get_ref::<TrustedInternalSubscription>()
Expand Down
103 changes: 94 additions & 9 deletions crates/graphql-orm-router/src/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ use crate::{
pub(crate) const INTERNAL_SUBSCRIPTION_HEADER: &str = "x-graphql-orm-router-internal";
pub(crate) const INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION: &str = "graphqlOrmRouterVariables";
const GRAPHQL_TRANSPORT_WS: &str = "graphql-transport-ws";
const MAX_AUTHORIZATION_VARIABLE_BYTES: usize = 4 * 1024;
const MAX_AUTHORIZATION_VARIABLE_VALUE_BYTES: usize = 1024;

#[derive(Clone)]
pub(crate) struct InternalSubscriptionEndpoint {
Expand Down Expand Up @@ -731,7 +733,8 @@ async fn forward_subscribe(
state.operations.insert(id.clone());
state.gateway.add_operation();
}
inject_operation_variables(&mut message);
let internal_max_bytes = state.borrow().gateway.config.max_client_message_bytes;
inject_operation_variables(&mut message, internal_max_bytes);
let internal = state.borrow().internal_sink.clone();
if let Some(internal) = internal
&& internal
Expand All @@ -747,24 +750,71 @@ async fn forward_subscribe(
.terminate_with_public_close(1011, "Subscription transport unavailable")
}

fn inject_operation_variables(message: &mut Value) {
fn inject_operation_variables(message: &mut Value, internal_max_bytes: usize) {
let Some(payload) = message.get_mut("payload").and_then(Value::as_object_mut) else {
return;
};
let variables = payload
let mut candidates = payload
.get("variables")
.cloned()
.unwrap_or_else(|| json!({}));
.and_then(Value::as_object)
.into_iter()
.flat_map(|variables| variables.iter())
.filter(|(_, value)| {
value.is_null() || value.is_boolean() || value.is_number() || value.is_string()
})
.filter_map(|(name, value)| {
let encoded = value.to_string();
(encoded.len() <= MAX_AUTHORIZATION_VARIABLE_VALUE_BYTES)
.then(|| (name.to_owned(), value.clone(), name.len() + encoded.len()))
})
.collect::<Vec<_>>();
candidates.sort_by(|left, right| left.0.cmp(&right.0));

let mut projected = serde_json::Map::new();
let mut projected_bytes = 0_usize;
for (name, value, encoded_bytes) in candidates {
if projected_bytes.saturating_add(encoded_bytes) > MAX_AUTHORIZATION_VARIABLE_BYTES {
break;
}
projected_bytes += encoded_bytes;
projected.insert(name, value);
}

let extensions = payload.entry("extensions").or_insert_with(|| json!({}));
let Some(extensions) = extensions.as_object_mut() else {
return;
};
// The public gateway is the only writer trusted by the private Hive
// endpoint. Always replace a client-supplied value for this reserved key.
// endpoint. Replace client-supplied reserved metadata with a small scalar
// projection. Large data variables remain only in the standard variables
// object, while missing authorization values fail closed at analysis.
extensions.insert(
INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION.to_owned(),
variables,
Value::Object(projected),
);

while message.to_string().len() > internal_max_bytes {
let Some(projected) = message
.get_mut("payload")
.and_then(Value::as_object_mut)
.and_then(|payload| payload.get_mut("extensions"))
.and_then(Value::as_object_mut)
.and_then(|extensions| extensions.get_mut(INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION))
.and_then(Value::as_object_mut)
else {
break;
};
let Some(name) = projected.keys().next_back().cloned() else {
message
.get_mut("payload")
.and_then(Value::as_object_mut)
.and_then(|payload| payload.get_mut("extensions"))
.and_then(Value::as_object_mut)
.map(|extensions| extensions.remove(INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION));
break;
};
projected.remove(&name);
}
}

async fn forward_complete(
Expand Down Expand Up @@ -871,7 +921,7 @@ mod tests {
}

#[test]
fn subscription_gateway_overwrites_reserved_variable_metadata() {
fn subscription_gateway_replaces_reserved_metadata_with_bounded_scalars() {
let mut message = json!({
"id": "operation",
"type": "subscribe",
Expand All @@ -885,15 +935,50 @@ mod tests {
}
});

inject_operation_variables(&mut message);
inject_operation_variables(&mut message, 64 * 1024);

assert_eq!(message["payload"]["variables"], json!({"Id": "actual"}));
assert_eq!(
message["payload"]["extensions"][INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION],
json!({"Id": "actual"})
);
assert_eq!(message["payload"]["extensions"]["client"], true);
}

#[test]
fn subscription_gateway_keeps_large_variable_payload_below_the_public_limit() {
let mut message = json!({
"id": "operation",
"type": "subscribe",
"payload": {
"query": "subscription ($Content: String!) { event(content: $Content) }",
"variables": {"Content": "x".repeat(48 * 1024)},
"extensions": {
INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION: {"Content": "spoofed"}
}
}
});
let public_size = message.to_string().len();

inject_operation_variables(&mut message, 64 * 1024);

let internal_size = message.to_string().len();
assert!(public_size < 64 * 1024);
assert_eq!(
message["payload"]["variables"]["Content"]
.as_str()
.unwrap()
.len(),
48 * 1024
);
assert!(
message["payload"]["extensions"][INTERNAL_SUBSCRIPTION_VARIABLES_EXTENSION]
.as_object()
.is_some_and(serde_json::Map::is_empty)
);
assert!(internal_size < 64 * 1024);
}

#[test]
fn connection_attempt_limiter_contains_churn_and_refills_gradually() {
let start = Instant::now();
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/workspace-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ changes.
| `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` |
| `graphql-orm-macros` | `0.30.0` | `crates/graphql-orm-macros` | `sqlite` | none |
| `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) |
| `graphql-orm-router` | `0.5.0` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` |
| `graphql-orm-router` | `0.5.1` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` |
| `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |
| `graphql-orm-storage` | `0.6.2` | `crates/graphql-orm-storage` | `local` | none |

Expand Down
Loading