diff --git a/README.md b/README.md index 3aa3f991..6b0d9d26 100644 --- a/README.md +++ b/README.md @@ -1420,7 +1420,7 @@ distributed = { version = "0.1", features = ["graphql", "postgres"] } ```rust,ignore use distributed::graphql::{ - claim, col, select, exposed_command, GraphqlCommands, GraphqlEngine, ModelPermissions, + claim, col, read, exposed_command, GraphqlCommands, GraphqlEngine, ModelPermissions, }; use distributed::microsvc::{Service, Session}; @@ -1428,13 +1428,11 @@ let engine = GraphqlEngine::from_manifest(&manifest, pool)? .roles(&["user", "admin", "anonymous"]) .model::( ModelPermissions::new() - .role( - "user", - select() + .grant("user", read() .all_columns() - .filter(col("owner_id").eq(claim("x-user-id"))), + .rows(col("owner_id").eq(claim("x-user-id"))), ) - .role("admin", select().all_columns()), // no owner filter + .grant("admin", read().all_columns()), // no owner filter ) .commands( GraphqlCommands::new() @@ -1471,23 +1469,27 @@ let service = Service::new() ### Permissions (deny by default) -Roles see only columns and rows you grant. Unmentioned models/roles fail closed. -Row filters can bind session claims (`claim("x-user-id")`, `claim("x-role")`, …) -so multi-tenant RLS lives in the engine, not ad-hoc handler SQL. +Three axes — **grant** a role, **columns** they may see, **rows** they may access. +Unmentioned models/roles fail closed (that is the deny). There is no separate +`.deny()` list: omit the role, narrow columns, or tighten `.rows(...)`. ```rust,ignore -use distributed::graphql::{select, col, claim, ModelPermissions}; +use distributed::graphql::{read, col, claim, ModelPermissions}; ModelPermissions::new() - .role( + .grant( "user", - select() + read() .all_columns() - .filter(col("owner_id").eq(claim("x-user-id"))), + .rows(col("owner_id").eq(claim("x-user-id"))), ) - .role("anonymous", select().columns(["id", "status"])); + .grant("admin", read().all_columns()) // all rows + .grant("anonymous", read().columns(["id", "status"])); ``` +Row predicates can bind session claims (`claim("x-user-id")`, …) so multi-tenant +RLS lives in the engine, not ad-hoc handler SQL. + ### Identity GraphQL does not invent a second session model. It builds a `microsvc::Session` diff --git a/distributed_cli/skills/distributed-graphql/SKILL.md b/distributed_cli/skills/distributed-graphql/SKILL.md index ac1e765a..31418262 100644 --- a/distributed_cli/skills/distributed-graphql/SKILL.md +++ b/distributed_cli/skills/distributed-graphql/SKILL.md @@ -27,17 +27,17 @@ src/query/ 2. Add `src/query/.rs`: ```rust -use distributed::graphql::{select, col, claim, ModelPermissions}; +use distributed::graphql::{read, col, claim, ModelPermissions}; use crate::read_models::OrderView; pub type Model = OrderView; pub fn permissions() -> ModelPermissions { ModelPermissions::new() - .role("user", select() + .grant("user", read() .all_columns() - .filter(col("customer_id").eq(claim("x-user-id")))) - .role("anonymous", select().columns(["order_id", "status"])) + .rows(col("customer_id").eq(claim("x-user-id")))) + .grant("anonymous", read().columns(["order_id", "status"])) } ``` diff --git a/distributed_cli/src/generate/service_crate.rs b/distributed_cli/src/generate/service_crate.rs index 78384973..c9d5f267 100644 --- a/distributed_cli/src/generate/service_crate.rs +++ b/distributed_cli/src/generate/service_crate.rs @@ -642,7 +642,7 @@ pub fn commands() -> GraphqlCommands { format!( r#"//! Permissions for `{view}`. -use distributed::graphql::{{select, ModelPermissions}}; +use distributed::graphql::{{read, ModelPermissions}}; use crate::read_models::{view}; @@ -651,8 +651,8 @@ pub type Model = {view}; pub fn permissions() -> ModelPermissions<{view}> {{ ModelPermissions::new() // Deny-by-default until roles are granted. grant_all(USER) in mod.rs - // covers the scaffold default; tighten columns/filters here for prod. - .role(super::roles::USER, select().all_columns().allow_aggregations(true)) + // covers the scaffold default; tighten .columns(...) / .rows(...) for prod. + .grant(super::roles::USER, read().all_columns().aggregations()) }} "#, view = model.view_ident, diff --git a/docs/graphql.md b/docs/graphql.md index cf71255c..0eb58ab3 100644 --- a/docs/graphql.md +++ b/docs/graphql.md @@ -39,7 +39,7 @@ src/query/ ## Quickstart ```rust -use distributed::graphql::{select, col, claim, GraphqlEngine}; +use distributed::graphql::{read, col, claim, GraphqlEngine}; use distributed::microsvc::{Service, Session}; let engine = GraphqlEngine::from_manifest(&manifest, pool)? @@ -82,14 +82,21 @@ Change them in GraphiQL’s **Headers** panel to exercise other roles. ## Permissions (deny by default) +**Grant** a role · **columns** they may see · **rows** they may access. +Omitting a role is deny. No separate `.deny()` ACL — tighten `.rows(...)` or +column allowlists instead. + ```rust -use distributed::graphql::{select, col, claim, ModelPermissions}; +use distributed::graphql::{read, col, claim, ModelPermissions}; ModelPermissions::new() - .role("user", select() - .all_columns() - .filter(col("customer_id").eq(claim("x-user-id")))) - .role("anonymous", select().columns(["order_id", "status"])); + .grant( + "user", + read() + .all_columns() + .rows(col("customer_id").eq(claim("x-user-id"))), + ) + .grant("anonymous", read().columns(["order_id", "status"])); ``` ## SDL artifact @@ -107,3 +114,7 @@ git diff --exit-code schema.graphql # CI gate - Event streaming (`_stream` cursors) - Remote schemas / joins - Querying operational tables (`outbox_messages`, event store, …) + +## Pre-release work (surface IR + client cache) + +See [pre-release-surface-ir-and-client-cache.md](./pre-release-surface-ir-and-client-cache.md) and GitKB `specs/query-layer/v1/*`. diff --git a/docs/pre-release-surface-ir-and-client-cache.md b/docs/pre-release-surface-ir-and-client-cache.md new file mode 100644 index 00000000..b8ae91b2 --- /dev/null +++ b/docs/pre-release-surface-ir-and-client-cache.md @@ -0,0 +1,15 @@ +# Pre-release: surface IR + client cache + +**Branch:** `feat/surface-ir-and-client-cache` +**Merges into:** `tasks--graphql-qs-epic` (not `main`) + +Normative designs live in **GitKB** (not this file): + +| Topic | Spec | State gaps | Epic | +|-------|------|------------|------| +| Shared GraphQL surface IR | `specs/query-layer/v1/surface-ir` | `specs/query-layer/v1/state` A* | `tasks/graphql-qs-surface-ir-1` | +| Browser query cache + optimistic commands | `specs/query-layer/v1/client-cache` | `specs/query-layer/v1/state` B* | `tasks/graphql-qs-client-cache-1` | +| Sequencing | — | — | `tasks/graphql-qs-prerelease-1` | + +**Server invariant unchanged:** GraphQL never writes read-model tables. Optimistic +updates are **browser cache only**. diff --git a/src/graphql/compile.rs b/src/graphql/compile.rs index 8f9640a1..e734b89d 100644 --- a/src/graphql/compile.rs +++ b/src/graphql/compile.rs @@ -26,7 +26,7 @@ use crate::table::{resolve_m2m_target_foreign_key, ColumnType, RelationshipKind, use super::engine::{CatalogEntry, EngineInner}; use super::filter::{CmpOp, FilterExpr, LitValue, Operand}; use super::naming::is_valid_graphql_name; -use super::permissions::SelectPermission; +use super::permissions::ReadPermission; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SqlDialect { @@ -423,7 +423,7 @@ fn compile_object_projection( session: &Session, role: &str, schema: &TableSchema, - perm: &SelectPermission, + perm: &ReadPermission, selection: &SelectionNode, alias: &str, binds: &mut Vec, @@ -479,7 +479,7 @@ fn compile_object_projection( .permissions .get(&(rel.target_model.clone(), role.to_string())) { - Some(p) if p.permission.allow_aggregations => &p.permission, + Some(p) if p.permission.aggregations => &p.permission, _ => continue, }; tables.push(target_entry.schema.table_name.clone()); @@ -587,7 +587,7 @@ fn compile_relationship_aggregate_subquery( source_alias: &str, rel: &crate::table::RelationshipDef, target: &CatalogEntry, - target_perm: &SelectPermission, + target_perm: &ReadPermission, selection: &SelectionNode, binds: &mut Vec, bytes_paths: &mut Vec, @@ -818,7 +818,7 @@ fn compile_relationship_subquery( source_alias: &str, rel: &crate::table::RelationshipDef, target: &CatalogEntry, - target_perm: &SelectPermission, + target_perm: &ReadPermission, selection: &SelectionNode, binds: &mut Vec, bytes_paths: &mut Vec, @@ -993,7 +993,7 @@ fn compile_m2m_subquery( target_fk: &str, target_schema: &TableSchema, target_pk: &str, - target_perm: &SelectPermission, + target_perm: &ReadPermission, selection: &SelectionNode, binds: &mut Vec, bytes_paths: &mut Vec, @@ -1068,7 +1068,7 @@ fn compile_order_by( schema: &TableSchema, order_arg: Option<&Value>, alias: &str, - perm: &SelectPermission, + perm: &ReadPermission, strict: bool, ) -> Result { let mut parts = Vec::new(); @@ -1123,7 +1123,7 @@ fn compile_where( session: &Session, role: &str, schema: &TableSchema, - perm: &SelectPermission, + perm: &ReadPermission, client_where: Option<&Value>, alias: &str, binds: &mut Vec, @@ -1134,7 +1134,7 @@ fn compile_where( return Err("max depth exceeded".into()); } let mut preds = Vec::new(); - if let Some(filter) = &perm.filter { + if let Some(filter) = &perm.row_filter { preds.push(compile_filter_expr( inner, session, schema, filter, alias, binds, tables, depth, )?); @@ -1401,7 +1401,7 @@ fn compile_client_where( session: &Session, role: &str, schema: &TableSchema, - perm: &SelectPermission, + perm: &ReadPermission, value: &Value, alias: &str, binds: &mut Vec, @@ -1947,7 +1947,7 @@ mod security_tests { #[cfg(test)] mod strict_order_by_tests { use super::*; - use crate::graphql::permissions::select; + use crate::graphql::permissions::read; use crate::table::{ColumnType, PrimaryKey, TableColumn, TableKind, TableSchema}; use async_graphql::indexmap::IndexMap; use async_graphql::Value as GqlValue; @@ -1989,7 +1989,7 @@ mod strict_order_by_tests { #[test] fn strict_rejects_unknown_order_column() { let schema = item_schema(); - let perm = select().all_columns(); + let perm = read().all_columns(); let arg = order_list(vec![("nope", "asc")]); let err = compile_order_by(&schema, Some(&arg), "t0", &perm, true).unwrap_err(); assert!(err.contains("unknown order_by"), "{err}"); @@ -1998,7 +1998,7 @@ mod strict_order_by_tests { #[test] fn strict_rejects_ungranted_order_column() { let schema = item_schema(); - let perm = select().columns(["id", "name"]); + let perm = read().columns(["id", "name"]); let arg = order_list(vec![("secret", "asc")]); let err = compile_order_by(&schema, Some(&arg), "t0", &perm, true).unwrap_err(); assert!(err.contains("ungranted order_by"), "{err}"); @@ -2007,7 +2007,7 @@ mod strict_order_by_tests { #[test] fn soft_skip_ignores_unknown_and_ungranted_order() { let schema = item_schema(); - let perm = select().columns(["id", "name"]); + let perm = read().columns(["id", "name"]); let arg = order_list(vec![("secret", "asc"), ("nope", "desc"), ("name", "desc")]); let sql = compile_order_by(&schema, Some(&arg), "t0", &perm, false).unwrap(); assert!(sql.contains(r#"t0."name" DESC"#), "{sql}"); @@ -2019,7 +2019,7 @@ mod strict_order_by_tests { #[test] fn strict_accepts_granted_order_with_pk_tiebreak() { let schema = item_schema(); - let perm = select().all_columns(); + let perm = read().all_columns(); let arg = order_list(vec![("name", "desc")]); let sql = compile_order_by(&schema, Some(&arg), "t0", &perm, true).unwrap(); assert!(sql.contains(r#"t0."name" DESC"#), "{sql}"); diff --git a/src/graphql/engine.rs b/src/graphql/engine.rs index afa59edf..77f6342a 100644 --- a/src/graphql/engine.rs +++ b/src/graphql/engine.rs @@ -24,7 +24,7 @@ use super::identity::IdentityConfig; use super::naming::{ by_pk_field, is_valid_graphql_name, object_type_name, reserved_type_names, root_list_field, }; -use super::permissions::{select, ModelPermissions, SelectPermission}; +use super::permissions::{read, ModelPermissions, ReadPermission}; use super::schema as dyn_schema; use super::sdl::{graphql_sdl_for_tables_with_options, SdlOptions}; @@ -81,7 +81,7 @@ pub(crate) struct CatalogEntry { #[derive(Clone)] pub(crate) struct RoleModelPerm { - pub permission: SelectPermission, + pub permission: ReadPermission, } pub(crate) struct EngineInner { @@ -427,7 +427,7 @@ impl GraphqlEngineBuilder { match self.permissions.entry(key) { Entry::Vacant(entry) => { entry.insert(RoleModelPerm { - permission: select().all_columns().allow_aggregations(true), + permission: read().all_columns().aggregations(), }); } Entry::Occupied(_) => { @@ -443,7 +443,7 @@ impl GraphqlEngineBuilder { pub fn permission( mut self, role: &str, - p: SelectPermission, + p: ReadPermission, ) -> Self { let model = M::schema().model_name.clone(); if !self.catalog.contains_key(&model) { @@ -591,7 +591,7 @@ impl GraphqlEngineBuilder { } } - if let Some(filter) = &perm.filter { + if let Some(filter) = &perm.row_filter { validate_filter( filter, &entry.schema, diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 4c752c44..9ca20436 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -63,7 +63,7 @@ pub use identity::{ UNSET_OIDC_AUDIENCE, UNSET_OIDC_ISSUER, }; #[cfg(feature = "graphql")] -pub use permissions::{select, ModelPermissions, SelectPermission}; +pub use permissions::{read, ModelPermissions, ReadPermission}; #[cfg(feature = "graphql")] pub use subscribe::ChangeHub; #[cfg(feature = "graphql")] diff --git a/src/graphql/permissions.rs b/src/graphql/permissions.rs index 1a34e391..acc6784e 100644 --- a/src/graphql/permissions.rs +++ b/src/graphql/permissions.rs @@ -1,56 +1,88 @@ -//! Role-based select permissions (deny-by-default column allowlists + row filters). +//! Role-based **read** permissions for GraphQL models. +//! +//! # Mental model (deny-by-default) +//! +//! 1. A role that is **not** listed for a model cannot see that model at all. +//! 2. **Columns** — explicit allowlist, or all columns. +//! 3. **Rows** — optional predicate; when set, every access path (list, by_pk, +//! relationships, aggregates) AND’s it into SQL `WHERE`. +//! +//! ```ignore +//! ModelPermissions::new() +//! .grant( +//! "user", +//! read() +//! .all_columns() +//! .rows(col("owner_id").eq(claim("x-user-id"))), +//! ) +//! .grant("admin", read().all_columns().aggregations()) +//! ``` +//! +//! Prefer this vocabulary over “filter” / bare “allow”: grants are roles, +//! columns are field allowlists, rows are row scope. use std::collections::BTreeSet; use std::marker::PhantomData; use super::filter::FilterExpr; -/// Per-role select permission for one model. +/// Per-role read access for one model. #[derive(Clone, Debug)] -pub struct SelectPermission { +pub struct ReadPermission { /// Allowed column names. Empty means no columns (deny-by-default start). pub(crate) columns: Option>, pub(crate) all_columns: bool, - pub(crate) filter: Option, + /// When set, rows must match this predicate (compiled into every WHERE). + pub(crate) row_filter: Option, pub(crate) limit: Option, - pub(crate) allow_aggregations: bool, + pub(crate) aggregations: bool, } -pub fn select() -> SelectPermission { - SelectPermission { +/// Start a deny-by-default read grant (no columns, no rows, no aggregations). +pub fn read() -> ReadPermission { + ReadPermission { columns: Some(BTreeSet::new()), all_columns: false, - filter: None, + row_filter: None, limit: None, - allow_aggregations: false, + aggregations: false, } } -impl SelectPermission { +impl ReadPermission { + /// Allow every column on the model for this role. pub fn all_columns(mut self) -> Self { self.all_columns = true; self.columns = None; self } + /// Allow only these columns (deny-by-default for the rest). pub fn columns>>(mut self, i: I) -> Self { self.all_columns = false; self.columns = Some(i.into_iter().map(Into::into).collect()); self } - pub fn filter(mut self, f: FilterExpr) -> Self { - self.filter = Some(f); + /// Restrict visible rows to those matching `predicate`. + /// + /// Compiled into the `WHERE` of list, by_pk, nested relationships, EXISTS + /// filters, and aggregates — not an optional soft filter. + pub fn rows(mut self, predicate: FilterExpr) -> Self { + self.row_filter = Some(predicate); self } + /// Cap the default page size for this role on this model (still clamped by + /// engine max_limit). pub fn limit(mut self, n: u64) -> Self { self.limit = Some(n); self } - pub fn allow_aggregations(mut self, on: bool) -> Self { - self.allow_aggregations = on; + /// Enable aggregate root / nested aggregate fields for this role. + pub fn aggregations(mut self) -> Self { + self.aggregations = true; self } @@ -76,9 +108,9 @@ impl SelectPermission { } } -/// Typed bag of `(role, SelectPermission)` pairs for one model. +/// Typed bag of `(role, ReadPermission)` pairs for one model. pub struct ModelPermissions { - pub(crate) entries: Vec<(String, SelectPermission)>, + pub(crate) entries: Vec<(String, ReadPermission)>, _marker: PhantomData, } @@ -96,8 +128,9 @@ impl ModelPermissions { } } - pub fn role(mut self, role: &str, p: SelectPermission) -> Self { - self.entries.push((role.to_string(), p)); + /// Grant `perm` to `role`. Roles never granted cannot query this model. + pub fn grant(mut self, role: &str, perm: ReadPermission) -> Self { + self.entries.push((role.to_string(), perm)); self } } diff --git a/src/graphql/schema.rs b/src/graphql/schema.rs index 569c0b39..d48066c9 100644 --- a/src/graphql/schema.rs +++ b/src/graphql/schema.rs @@ -19,7 +19,7 @@ use super::naming::{ bool_exp_name, by_pk_field, comparison_exp_name, include_postgres_json_comparison_ops, object_type_name, order_by_name, root_list_field, scalar_type_name, CUSTOM_SCALARS, }; -use super::permissions::SelectPermission; +use super::permissions::ReadPermission; pub fn build_role_schema( role: &str, @@ -35,7 +35,7 @@ pub fn build_role_schema( let _ = by_table; // Collect models granted to this role. - let granted: Vec<(&str, &TableSchema, &SelectPermission)> = permissions + let granted: Vec<(&str, &TableSchema, &ReadPermission)> = permissions .iter() .filter(|((_, r), _)| r == role) .filter_map(|((model, _), perm)| { @@ -131,7 +131,7 @@ pub fn build_role_schema( query = query.field(pk_field); // Aggregate root when allowed - if perm.allow_aggregations { + if perm.aggregations { let agg_name = format!("{}_aggregate", schema.table_name); let agg_type = format!("{}_aggregate", schema.table_name); ensure_aggregate_type(&mut registered_objects, schema); @@ -187,15 +187,16 @@ pub fn build_role_schema( } // Empty-role Query must still define ≥1 field (async-graphql requirement). - // Spec: empty role → FORBIDDEN fixed response on any selection. + // Spec: empty role → FORBIDDEN with extensions.code (no query surface). if granted.is_empty() { query = query.field(Field::new( "_empty", TypeRef::named_nn(TypeRef::BOOLEAN), |_| { FieldFuture::new(async { - Err::, _>(async_graphql::Error::new( - "FORBIDDEN: role has no GraphQL grants", + Err::, _>(client_error( + "FORBIDDEN", + "role has no GraphQL grants", )) }) }, @@ -339,7 +340,7 @@ fn ensure_object_type( catalog: &BTreeMap, permissions: &BTreeMap<(String, String), RoleModelPerm>, role: &str, - perm: &SelectPermission, + perm: &ReadPermission, scalars: &mut std::collections::BTreeSet<&'static str>, ) { let name = object_type_name(schema).to_string(); @@ -427,7 +428,7 @@ fn ensure_object_type( .argument(InputValue::new("limit", TypeRef::named(TypeRef::INT))) .argument(InputValue::new("offset", TypeRef::named(TypeRef::INT))); obj = obj.field(field); - if target_perm.permission.allow_aggregations { + if target_perm.permission.aggregations { ensure_aggregate_type(objects, &target.schema); let agg_key = format!("{}_aggregate", rel.field_name); let agg_type = format!("{}_aggregate", target.schema.table_name); @@ -451,7 +452,7 @@ fn ensure_bool_exp( catalog: &BTreeMap, permissions: &BTreeMap<(String, String), RoleModelPerm>, role: &str, - perm: &SelectPermission, + perm: &ReadPermission, scalars: &mut std::collections::BTreeSet<&'static str>, ) { let name = bool_exp_name(schema); @@ -517,7 +518,7 @@ fn ensure_bool_exp( fn ensure_order_by_input( inputs: &mut BTreeMap, schema: &TableSchema, - perm: &SelectPermission, + perm: &ReadPermission, ) { let name = order_by_name(schema); if inputs.contains_key(&name) { @@ -688,6 +689,19 @@ async fn resolve_root( } } +/// Closed set of engine-authored GraphQL `extensions.code` values (v1 freeze). +/// Async-graphql document validation may still emit uncoded errors. +#[allow(dead_code)] // public contract constant; asserted in unit tests +pub const ENGINE_ERROR_CODES: &[&str] = &[ + "BAD_REQUEST", + "FORBIDDEN", + "TIMEOUT", + "INTERNAL", + "UNAUTHORIZED", // command mutations + "NOT_FOUND", // command mutations + "REJECTED", // command mutations +]; + /// Map executor error strings to stable client errors (`extensions.code`). pub(crate) fn client_error_for_execute_err(e: &str) -> async_graphql::Error { if e.contains("timeout") { @@ -732,9 +746,68 @@ fn client_error(code: &str, message: impl Into) -> async_graphql::Error }) } +/// Command-mutation errors also carry numeric HTTP `extensions.status`. +fn client_error_with_status( + code: &str, + status: u16, + message: impl Into, +) -> async_graphql::Error { + use async_graphql::ErrorExtensions; + let code = code.to_string(); + async_graphql::Error::new(message.into()).extend_with(move |_, ext| { + ext.set("code", code.as_str()); + ext.set("status", status as i32); + }) +} + #[cfg(test)] mod execute_err_mapping_tests { - use super::{client_error_for_execute_err, sanitize_compile_error}; + use super::{client_error_for_execute_err, sanitize_compile_error, ENGINE_ERROR_CODES}; + + #[test] + fn engine_error_codes_closed_set_v1() { + // Freeze: expanding this set is a client-breaking change. + assert_eq!( + ENGINE_ERROR_CODES, + &[ + "BAD_REQUEST", + "FORBIDDEN", + "TIMEOUT", + "INTERNAL", + "UNAUTHORIZED", + "NOT_FOUND", + "REJECTED", + ] + ); + } + + #[test] + fn command_status_code_maps_http_to_frozen_codes() { + use super::command_status_code; + assert_eq!(command_status_code(400), "BAD_REQUEST"); + assert_eq!(command_status_code(401), "UNAUTHORIZED"); + assert_eq!(command_status_code(403), "FORBIDDEN"); + assert_eq!(command_status_code(404), "NOT_FOUND"); + assert_eq!(command_status_code(422), "REJECTED"); + // Undocumented 4xx (e.g. 409) → BAD_REQUEST, never CONFLICT + assert_eq!(command_status_code(409), "BAD_REQUEST"); + assert_eq!(command_status_code(418), "BAD_REQUEST"); + assert_eq!(command_status_code(500), "INTERNAL"); + assert_eq!(command_status_code(503), "INTERNAL"); + for code in [ + command_status_code(400), + command_status_code(401), + command_status_code(404), + command_status_code(422), + command_status_code(409), + command_status_code(500), + ] { + assert!( + ENGINE_ERROR_CODES.contains(&code), + "command_status_code emitted undocumented code {code}" + ); + } + } #[test] fn statement_timeout_maps_to_timeout_code() { @@ -813,7 +886,8 @@ async fn resolve_command( .unwrap_or_else(Session::new); let service = ctx.data_opt::>(); let Some(service) = service else { - return Err(async_graphql::Error::new( + return Err(client_error( + "INTERNAL", "command dispatcher not configured (use graphql_router_with_service)", )); }; @@ -823,7 +897,7 @@ async fn resolve_command( .get("input") .map(|v| v.deserialize::()) .transpose() - .map_err(|e| async_graphql::Error::new(format!("{e:?}")))? + .map_err(|e| client_error("BAD_REQUEST", format!("invalid command input: {e:?}")))? .unwrap_or(serde_json::json!({})); let request = CommandRequest { @@ -832,8 +906,9 @@ async fn resolve_command( session_variables: session.variables().clone(), }; let response = service.dispatch_request(&request).await; - // Map status → GraphQL error codes + // Map status → GraphQL error with extensions.code (+ status) — frozen v1 contract. if response.status >= 400 { + let code = command_status_code(response.status); let msg = if response.status >= 500 { "internal error".to_string() } else { @@ -844,24 +919,25 @@ async fn resolve_command( .unwrap_or("request failed") .to_string() }; - return Err(async_graphql::Error::new(format!( - "{msg} [{}]", - status_code_name(response.status) - ))); + return Err(client_error_with_status(code, response.status, msg)); } Value::from_json(response.body) .map(Some) - .map_err(|e| async_graphql::Error::new(format!("response encode: {e}"))) + .map_err(|e| client_error("INTERNAL", format!("response encode: {e}"))) } -fn status_code_name(status: u16) -> &'static str { +/// Map HTTP command status → frozen `extensions.code` (see security/http specs). +/// +/// 400→BAD_REQUEST, 401→UNAUTHORIZED, 404→NOT_FOUND, 422→REJECTED, +/// other 4xx→BAD_REQUEST, 5xx→INTERNAL. No undocumented codes (e.g. CONFLICT). +pub(crate) fn command_status_code(status: u16) -> &'static str { match status { 400 => "BAD_REQUEST", 401 => "UNAUTHORIZED", 403 => "FORBIDDEN", 404 => "NOT_FOUND", - 409 => "CONFLICT", - _ if status >= 500 => "INTERNAL", - _ => "BAD_REQUEST", + 422 => "REJECTED", + s if (400..500).contains(&s) => "BAD_REQUEST", + _ => "INTERNAL", } } diff --git a/tests/e2e-ui/crates/service/src/service.rs b/tests/e2e-ui/crates/service/src/service.rs index 46795c2b..43b3c95e 100644 --- a/tests/e2e-ui/crates/service/src/service.rs +++ b/tests/e2e-ui/crates/service/src/service.rs @@ -2,7 +2,7 @@ use chat_domain::ChatMessage; use distributed::graphql::{ - exposed_command, select, GraphqlCommands, GraphqlEngine, GraphqlPool, IdentityConfig, + exposed_command, read, GraphqlCommands, GraphqlEngine, GraphqlPool, IdentityConfig, ModelPermissions, OidcConfig, }; use distributed::microsvc::{ @@ -146,19 +146,17 @@ pub fn build_graphql_engine( // user: only own rows. admin: all owners (UI: /admin all-notes view). .model::( ModelPermissions::new() - .role( - "user", - select().all_columns().filter( + .grant("user", read().all_columns().rows( distributed::graphql::col("owner_id") .eq(distributed::graphql::claim("x-user-id")), ), ) - .role("admin", select().all_columns()), + .grant("admin", read().all_columns()), ) .model::( ModelPermissions::new() - .role("user", select().all_columns()) - .role("admin", select().all_columns()), + .grant("user", read().all_columns()) + .grant("admin", read().all_columns()), ) .commands(commands) .identity(identity) diff --git a/tests/graphql_commands/main.rs b/tests/graphql_commands/main.rs index 89c8c443..04e528ba 100644 --- a/tests/graphql_commands/main.rs +++ b/tests/graphql_commands/main.rs @@ -248,6 +248,168 @@ async fn standalone_router_without_service_returns_no_dispatcher() { || err.contains("not configured"), "expected no-dispatcher error, got {err}" ); + let code = resp.errors[0] + .extensions + .as_ref() + .and_then(|ext| ext.get("code")) + .map(|v| format!("{v}")); + assert!( + code.as_deref().is_some_and(|c| c.contains("INTERNAL")), + "no-dispatcher must set extensions.code=INTERNAL, got {code:?}" + ); +} + +/// Empty-role schema returns FORBIDDEN with extensions.code (frozen contract). +#[tokio::test] +async fn empty_role_forbidden_sets_extensions_code() { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query( + "CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT NOT NULL, _sourced_version INTEGER NOT NULL DEFAULT 0)", + ) + .execute(&pool) + .await + .unwrap(); + + let manifest = + distributed::DistributedProjectManifest::new("items").table_schema(items_schema()); + // Role "nobody" is declared but never granted any model → empty grant surface. + let engine = GraphqlEngine::from_manifest(&manifest, pool) + .unwrap() + .roles(&["user", "nobody"]) + .grant_all("user") + .build() + .expect("build"); + + let mut session = Session::new(); + session.set(ROLE_KEY, "nobody"); + let resp = engine + .execute(&session, Request::new("{ _empty }")) + .await; + assert!(resp.is_err(), "empty role must error"); + let err = &resp.errors[0]; + assert!( + err.message.contains("no GraphQL grants") || err.message.contains("FORBIDDEN"), + "message: {}", + err.message + ); + let code = err + .extensions + .as_ref() + .and_then(|ext| ext.get("code")) + .map(|v| format!("{v}")); + assert!( + code.as_deref().is_some_and(|c| c.contains("FORBIDDEN")), + "empty role must set extensions.code=FORBIDDEN, got {code:?} err={err:?}" + ); +} + +/// Command mutation maps handler HTTP status → extensions.code (+ status). +#[tokio::test] +async fn command_mutation_errors_set_extensions_code_and_status() { + use distributed::microsvc::HandlerError; + + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query( + "CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT NOT NULL, _sourced_version INTEGER NOT NULL DEFAULT 0)", + ) + .execute(&pool) + .await + .unwrap(); + + async fn assert_code( + engine: &GraphqlEngine, + service: &Arc, + session: &Session, + status_hint: &str, + expect_code: &str, + expect_status: i64, + ) { + let req = Request::new(format!( + r#"mutation {{ fail_cmd(input: {{ want: "{status_hint}" }}) }}"# + )) + .data(Arc::clone(service)); + let resp = engine.execute(session, req).await; + assert!( + resp.is_err(), + "expected error for {status_hint}, got {:?}", + resp.data + ); + let err = &resp.errors[0]; + let ext = err.extensions.as_ref().expect("extensions present"); + let code = ext.get("code").map(|v| format!("{v}")); + let status = ext.get("status").map(|v| format!("{v}")); + assert!( + code.as_deref().is_some_and(|c| c.contains(expect_code)), + "want extensions.code={expect_code} for {status_hint}, got code={code:?} msg={}", + err.message + ); + assert!( + status + .as_deref() + .is_some_and(|s| s.contains(&expect_status.to_string())), + "want extensions.status={expect_status} for {status_hint}, got {status:?}" + ); + // Message must not embed [CODE] bracket form (legacy). + assert!( + !err.message.contains(&format!("[{expect_code}]")), + "legacy [CODE] in message: {}", + err.message + ); + } + + let routes = Routes::new() + .command("item.fail") + .handle(|ctx: &Context<()>| { + let want = ctx + .raw_input() + .get("want") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + async move { + match want.as_str() { + "401" => Err(HandlerError::Unauthorized("nope".into())), + "404" => Err(HandlerError::NotFound("missing".into())), + "422" => Err(HandlerError::Rejected("invalid".into())), + "400" => Err(HandlerError::DecodeFailed("bad".into())), + "500" => Err(HandlerError::Other(Box::new(std::io::Error::other("boom")))), + _ => Ok(json!({ "ok": true })), + } + } + }); + let service = Arc::new(Service::new().routes(routes)); + + let commands = GraphqlCommands::new().command( + "item.fail", + exposed_command() + .field_name("fail_cmd") + .input_json() + .roles(["user"]), + ); + let manifest = + distributed::DistributedProjectManifest::new("items").table_schema(items_schema()); + let engine = GraphqlEngine::from_manifest(&manifest, pool) + .unwrap() + .roles(&["user"]) + .grant_all("user") + .commands(commands) + .build() + .expect("build"); + + let mut session = Session::new(); + session.set(ROLE_KEY, "user"); + + assert_code(&engine, &service, &session, "401", "UNAUTHORIZED", 401).await; + assert_code(&engine, &service, &session, "404", "NOT_FOUND", 404).await; + assert_code(&engine, &service, &session, "422", "REJECTED", 422).await; + assert_code(&engine, &service, &session, "400", "BAD_REQUEST", 400).await; + assert_code(&engine, &service, &session, "500", "INTERNAL", 500).await; } #[test] diff --git a/tests/graphql_compile/main.rs b/tests/graphql_compile/main.rs index f96aba49..28f63a70 100644 --- a/tests/graphql_compile/main.rs +++ b/tests/graphql_compile/main.rs @@ -4,7 +4,7 @@ #![cfg(all(feature = "graphql", feature = "sqlite"))] use async_graphql::Request; -use distributed::graphql::{select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions}; use distributed::microsvc::{Session, ROLE_KEY}; use distributed::ReadModel; use serde::{Deserialize, Serialize}; @@ -39,7 +39,7 @@ async fn engine() -> GraphqlEngine { .unwrap(); GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .build() .unwrap() } diff --git a/tests/graphql_harden/authz.rs b/tests/graphql_harden/authz.rs index 9f980022..f9ed70c0 100644 --- a/tests/graphql_harden/authz.rs +++ b/tests/graphql_harden/authz.rs @@ -2,7 +2,7 @@ //! Drives shipped `GraphqlEngine::execute`. use async_graphql::Request; -use distributed::graphql::{claim, col, select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{claim, col, read, GraphqlEngine, ModelPermissions}; use distributed::{ DistributedProjectManifest, RelationalReadModel, RelationshipDef, RelationshipKind, }; @@ -19,11 +19,9 @@ async fn claim_row_filter_isolates_tenants() { let engine = GraphqlEngine::builder(pool) .roles(&["user", "anonymous"]) .model::( - ModelPermissions::new().role( - "user", - select() + ModelPermissions::new().grant("user", read() .all_columns() - .filter(col("customer_id").eq(claim("x-user-id"))), + .rows(col("customer_id").eq(claim("x-user-id"))), ), ) .build() @@ -61,11 +59,9 @@ async fn a2_claim_filter_on_by_pk_isolates_tenants() { let engine = GraphqlEngine::builder(pool) .roles(&["user"]) .model::( - ModelPermissions::new().role( - "user", - select() + ModelPermissions::new().grant("user", read() .all_columns() - .filter(col("customer_id").eq(claim("x-user-id"))), + .rows(col("customer_id").eq(claim("x-user-id"))), ), ) .build() @@ -101,12 +97,10 @@ async fn a3_claim_filter_on_aggregate_count() { let engine = GraphqlEngine::builder(pool) .roles(&["user"]) .model::( - ModelPermissions::new().role( - "user", - select() + ModelPermissions::new().grant("user", read() .all_columns() - .allow_aggregations(true) - .filter(col("customer_id").eq(claim("x-user-id"))), + .aggregations() + .rows(col("customer_id").eq(claim("x-user-id"))), ), ) .build() @@ -145,8 +139,8 @@ async fn column_allowlist_denies_ungranted_fields() { .roles(&["restricted", "user"]) .model::( ModelPermissions::new() - .role("restricted", select().columns(["order_id", "status"])) - .role("user", select().all_columns()), + .grant("restricted", read().columns(["order_id", "status"])) + .grant("user", read().all_columns()), ) .build() .unwrap(); @@ -215,8 +209,8 @@ async fn a5_nested_relationship_column_allowlist_denies() { let engine = GraphqlEngine::from_manifest(&manifest, pool) .unwrap() .roles(&["user"]) - .permission::("user", select().all_columns()) - .permission::("user", select().columns(["child_id", "parent_id"])) + .permission::("user", read().all_columns()) + .permission::("user", read().columns(["child_id", "parent_id"])) .build() .expect("build"); diff --git a/tests/graphql_harden/dialect.rs b/tests/graphql_harden/dialect.rs index 596fdf7d..1be7c275 100644 --- a/tests/graphql_harden/dialect.rs +++ b/tests/graphql_harden/dialect.rs @@ -6,7 +6,7 @@ use async_graphql::Request; use distributed::graphql::{ - comparison_op_fields, include_postgres_json_comparison_ops, select, GraphqlEngine, + comparison_op_fields, include_postgres_json_comparison_ops, read, GraphqlEngine, ModelPermissions, POSTGRES_JSON_COMPARISON_OPS, }; use distributed::ReadModel; @@ -42,7 +42,7 @@ async fn seed_docs() -> sqlx::SqlitePool { fn engine(pool: sqlx::SqlitePool) -> GraphqlEngine { GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .build() .unwrap() } diff --git a/tests/graphql_harden/dos.rs b/tests/graphql_harden/dos.rs index 4ec4a7ae..24ee1587 100644 --- a/tests/graphql_harden/dos.rs +++ b/tests/graphql_harden/dos.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::time::Duration; use async_graphql::Request; -use distributed::graphql::{select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions}; use super::common::{ assert_no_sql_leak, engine_all_columns, error_messages, seed_orders, session, OrderView, @@ -15,7 +15,7 @@ async fn where_max_depth_rejected() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .max_depth(2) .build() .unwrap(); @@ -40,7 +40,7 @@ async fn max_in_list_rejected() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .max_in_list(3) .build() .unwrap(); @@ -56,7 +56,7 @@ async fn limit_clamped_by_max_limit() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .max_limit(1) .default_limit(1) .build() @@ -76,7 +76,7 @@ async fn d5_wide_or_list_rejected_by_max_bool_width() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .max_bool_width(3) .build() .unwrap(); @@ -109,7 +109,7 @@ async fn d5_wide_and_list_rejected_by_max_bool_width() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .max_bool_width(2) .build() .unwrap(); @@ -184,7 +184,7 @@ async fn d7_concurrent_with_timeout_bound_terminates() { let engine = Arc::new( GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .statement_timeout(Duration::from_secs(2)) .build() .unwrap(), @@ -219,7 +219,7 @@ async fn d8_nested_has_many_exceeds_complexity_budget() { use distributed::{ DistributedProjectManifest, RelationalReadModel, RelationshipDef, RelationshipKind, }; - use distributed::graphql::{select, GraphqlEngine}; + use distributed::graphql::{read, GraphqlEngine}; use distributed::ReadModel; use serde::{Deserialize, Serialize}; @@ -296,9 +296,9 @@ async fn d8_nested_has_many_exceeds_complexity_budget() { .unwrap() .roles(&["user"]) .max_depth(8) - .permission::("user", select().all_columns()) - .permission::("user", select().all_columns()) - .permission::("user", select().all_columns()) + .permission::("user", read().all_columns()) + .permission::("user", read().all_columns()) + .permission::("user", read().all_columns()) .build() .expect("build"); @@ -336,7 +336,7 @@ async fn d8_shallow_nested_has_many_within_budget() { use distributed::{ DistributedProjectManifest, RelationalReadModel, RelationshipDef, RelationshipKind, }; - use distributed::graphql::{select, GraphqlEngine}; + use distributed::graphql::{read, GraphqlEngine}; use distributed::ReadModel; use serde::{Deserialize, Serialize}; @@ -388,8 +388,8 @@ async fn d8_shallow_nested_has_many_within_budget() { let engine = GraphqlEngine::from_manifest(&manifest, pool) .unwrap() .roles(&["user"]) - .permission::("user", select().all_columns()) - .permission::("user", select().all_columns()) + .permission::("user", read().all_columns()) + .permission::("user", read().all_columns()) .build() .expect("build"); @@ -413,7 +413,7 @@ async fn d8_low_max_complexity_rejects_single_nest() { use distributed::{ DistributedProjectManifest, RelationalReadModel, RelationshipDef, RelationshipKind, }; - use distributed::graphql::{select, GraphqlEngine}; + use distributed::graphql::{read, GraphqlEngine}; use distributed::ReadModel; use serde::{Deserialize, Serialize}; @@ -467,8 +467,8 @@ async fn d8_low_max_complexity_rejects_single_nest() { .roles(&["user"]) .max_complexity(20) .max_depth(8) - .permission::("user", select().all_columns()) - .permission::("user", select().all_columns()) + .permission::("user", read().all_columns()) + .permission::("user", read().all_columns()) .build() .expect("build"); diff --git a/tests/graphql_harden/errors.rs b/tests/graphql_harden/errors.rs index d586c672..2020809a 100644 --- a/tests/graphql_harden/errors.rs +++ b/tests/graphql_harden/errors.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::time::Duration; use async_graphql::Request; -use distributed::graphql::{select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions}; use super::common::{ assert_no_sql_leak, engine_all_columns, error_messages, extension_code, seed_orders, session, @@ -17,7 +17,7 @@ async fn compile_errors_do_not_leak_sql() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .max_in_list(1) .build() .unwrap(); @@ -69,7 +69,7 @@ async fn e3_sqlite_statement_timeout_returns_timeout_code() { let engine = GraphqlEngine::builder(pool.clone()) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .statement_timeout(Duration::from_millis(80)) .build() .unwrap(); diff --git a/tests/graphql_harden/inject.rs b/tests/graphql_harden/inject.rs index 390bb6ef..2a7440cb 100644 --- a/tests/graphql_harden/inject.rs +++ b/tests/graphql_harden/inject.rs @@ -1,7 +1,7 @@ //! S* Injection red-team suite — real `GraphqlEngine::execute` only. use async_graphql::Request; -use distributed::graphql::{select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions}; use super::common::{ assert_no_sql_leak, engine_all_columns, exec_json, seed_orders, session, @@ -147,7 +147,7 @@ async fn s2_denied_where_column_fails_closed() { let engine = GraphqlEngine::builder(pool) .roles(&["restricted"]) .model::( - ModelPermissions::new().role("restricted", select().columns(["order_id", "status"])), + ModelPermissions::new().grant("restricted", read().columns(["order_id", "status"])), ) .build() .unwrap(); diff --git a/tests/graphql_harden/residual.rs b/tests/graphql_harden/residual.rs index 02d75c07..8e20f3da 100644 --- a/tests/graphql_harden/residual.rs +++ b/tests/graphql_harden/residual.rs @@ -1,7 +1,7 @@ //! Residual red-team cases from post-quality-1 review (A8/A12/S9/E4/E6). use async_graphql::Request; -use distributed::graphql::{claim, col, select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{claim, col, read, GraphqlEngine, ModelPermissions}; use distributed::{ DistributedProjectManifest, RelationalReadModel, RelationshipDef, RelationshipKind, }; @@ -50,7 +50,7 @@ async fn a8_nested_has_many_without_child_grant_is_unknown_field() { let engine = GraphqlEngine::from_manifest(&manifest, pool) .unwrap() .roles(&["user"]) - .permission::("user", select().all_columns()) + .permission::("user", read().all_columns()) .build() .expect("build"); @@ -119,7 +119,7 @@ async fn a12_rel_where_without_target_grant_is_unknown_field() { let engine = GraphqlEngine::from_manifest(&manifest, pool) .unwrap() .roles(&["user"]) - .permission::("user", select().all_columns()) + .permission::("user", read().all_columns()) // no ChildView permission .build() .expect("build"); @@ -185,11 +185,9 @@ async fn e4_missing_claim_header_is_stable_without_sql_leak() { let engine = GraphqlEngine::builder(pool) .roles(&["user"]) .model::( - ModelPermissions::new().role( - "user", - select() + ModelPermissions::new().grant("user", read() .all_columns() - .filter(col("customer_id").eq(claim("x-user-id"))), + .rows(col("customer_id").eq(claim("x-user-id"))), ), ) .build() diff --git a/tests/graphql_harden/softskip.rs b/tests/graphql_harden/softskip.rs index 9d631369..dc55d89c 100644 --- a/tests/graphql_harden/softskip.rs +++ b/tests/graphql_harden/softskip.rs @@ -9,7 +9,7 @@ //! reject keys absent from `*_bool_exp` / `*_order_by` before compile runs. use async_graphql::Request; -use distributed::graphql::{select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions}; use super::common::{ assert_no_sql_leak, engine_all_columns, error_messages, extension_code, seed_orders, session, @@ -61,9 +61,7 @@ async fn strict_denied_where_column_errors() { let engine = GraphqlEngine::builder(pool) .roles(&["restricted"]) .model::( - ModelPermissions::new().role( - "restricted", - select().columns(["order_id", "status"]), + ModelPermissions::new().grant("restricted", read().columns(["order_id", "status"]), ), ) .build() @@ -143,7 +141,7 @@ async fn soft_skip_mode_is_opt_in_only() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .strict_where(false) .build() .unwrap(); diff --git a/tests/graphql_harden/transport.rs b/tests/graphql_harden/transport.rs index 66bdfb48..e85b17cc 100644 --- a/tests/graphql_harden/transport.rs +++ b/tests/graphql_harden/transport.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use async_graphql::Request; use distributed::graphql::{ - exposed_command, graphiql_enabled_from_env_vars, select, GraphqlCommands, GraphqlEngine, + exposed_command, graphiql_enabled_from_env_vars, read, GraphqlCommands, GraphqlEngine, ModelPermissions, }; use distributed::microsvc::{router, Service, Session}; @@ -21,7 +21,7 @@ async fn anonymous_introspection_disabled_when_flag_false() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user", "anonymous"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .introspection_for_anonymous(false) .build() .unwrap(); @@ -58,7 +58,7 @@ async fn anonymous_introspection_allowed_when_flag_true() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user", "anonymous"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .introspection_for_anonymous(true) .build() .unwrap(); @@ -77,7 +77,7 @@ async fn t3_introspection_over_http_respects_role() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user", "anonymous"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .introspection_for_anonymous(false) .graphiql(false) .build() @@ -169,9 +169,7 @@ async fn t4_mutation_absent_without_command_grant() { let engine = GraphqlEngine::builder(pool) .roles(&["user", "admin"]) - .model::(ModelPermissions::new().role("user", select().all_columns()).role( - "admin", - select().all_columns(), + .model::(ModelPermissions::new().grant("user", read().all_columns()).grant("admin", read().all_columns(), )) .commands(cmds) .build() @@ -214,7 +212,7 @@ async fn production_env_policy_disables_graphiql_http_get() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .graphiql(graphiql) .build() .unwrap(); @@ -239,7 +237,7 @@ async fn graphql_metrics_increment_on_execute() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .build() .unwrap(); let s = session("user", "x"); @@ -264,7 +262,7 @@ async fn graphql_metrics_bad_request_status_label() { let pool = seed_orders().await; let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .max_bool_width(2) .build() .unwrap(); diff --git a/tests/graphql_http/main.rs b/tests/graphql_http/main.rs index 3997d61f..c0635e44 100644 --- a/tests/graphql_http/main.rs +++ b/tests/graphql_http/main.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use distributed::graphql::{ - graphiql_enabled_from_env_vars, select, GraphqlEngine, ModelPermissions, + graphiql_enabled_from_env_vars, read, GraphqlEngine, ModelPermissions, }; use distributed::microsvc::{router, Service}; use distributed::ReadModel; @@ -35,7 +35,7 @@ async fn service_with_graphiql(on: bool) -> Arc { .unwrap(); let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .graphiql(on) .build() .unwrap(); diff --git a/tests/graphql_identity/main.rs b/tests/graphql_identity/main.rs index 255513e7..af9aa5a3 100644 --- a/tests/graphql_identity/main.rs +++ b/tests/graphql_identity/main.rs @@ -10,8 +10,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use axum::http::{HeaderMap, HeaderValue}; use base64::Engine; use distributed::graphql::{ - extract_bearer, graphql_router, map_claims_to_session, resolve_session_sync, select, - strip_identity_headers, AuthError, ClaimMapConfig, GraphqlEngine, IdentityConfig, IdentityMode, + extract_bearer, graphql_router, map_claims_to_session, resolve_session_sync, read, strip_identity_headers, AuthError, ClaimMapConfig, GraphqlEngine, IdentityConfig, IdentityMode, ModelPermissions, OidcConfig, OidcValidator, DEFAULT_IDENTITY_STRIP_HEADERS, }; use distributed::ReadModel; @@ -403,15 +402,13 @@ async fn engine_with_identity(identity: IdentityConfig) -> Arc { .roles(&["customer", "admin", "user"]) .model::( ModelPermissions::new() - .role( - "customer", - select().all_columns().filter( + .grant("customer", read().all_columns().rows( distributed::graphql::col("owner") .eq(distributed::graphql::claim("x-user-id")), ), ) - .role("admin", select().all_columns()) - .role("user", select().all_columns()), + .grant("admin", read().all_columns()) + .grant("user", read().all_columns()), ) .identity(identity) .graphiql(false) diff --git a/tests/graphql_oidc_common/mod.rs b/tests/graphql_oidc_common/mod.rs index cf2c2444..5cf5ad06 100644 --- a/tests/graphql_oidc_common/mod.rs +++ b/tests/graphql_oidc_common/mod.rs @@ -9,7 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; use base64::Engine as _; use distributed::graphql::{ - graphql_router, resolve_session, select, AuthError, GraphqlEngine, IdentityConfig, + graphql_router, resolve_session, read, AuthError, GraphqlEngine, IdentityConfig, IdentityMode, ModelPermissions, OidcConfig, OidcValidator, ValidationError, }; use distributed::ReadModel; @@ -79,15 +79,13 @@ pub async fn engine_oidc(oidc: OidcConfig) -> Arc { .roles(&["customer", "admin", "user"]) .model::( ModelPermissions::new() - .role( - "customer", - select().all_columns().filter( + .grant("customer", read().all_columns().rows( distributed::graphql::col("owner") .eq(distributed::graphql::claim("x-user-id")), ), ) - .role("admin", select().all_columns()) - .role("user", select().all_columns()), + .grant("admin", read().all_columns()) + .grant("user", read().all_columns()), ) .identity(IdentityConfig::oidc_bearer(oidc)) .graphiql(false) @@ -177,21 +175,17 @@ pub async fn run_e1_through_e8( .roles(&["customer", "admin", "user"]) .model::( ModelPermissions::new() - .role( - "customer", - select().all_columns().filter( + .grant("customer", read().all_columns().rows( distributed::graphql::col("owner") .eq(distributed::graphql::claim("x-user-id")), ), ) - .role( - "user", - select().all_columns().filter( + .grant("user", read().all_columns().rows( distributed::graphql::col("owner") .eq(distributed::graphql::claim("x-user-id")), ), ) - .role("admin", select().all_columns()), + .grant("admin", read().all_columns()), ) .identity(IdentityConfig::oidc_bearer(oidc_cfg.clone())) .graphiql(false) @@ -335,7 +329,7 @@ pub async fn run_e1_through_e8( GraphqlEngine::builder(pool) .roles(&["customer", "admin", "user"]) .model::( - ModelPermissions::new().role("customer", select().all_columns()), + ModelPermissions::new().grant("customer", read().all_columns()), ) .identity(IdentityConfig::oidc_bearer(http_cfg)) .graphiql(false) diff --git a/tests/graphql_postgres/main.rs b/tests/graphql_postgres/main.rs index 31e167c1..239b91f6 100644 --- a/tests/graphql_postgres/main.rs +++ b/tests/graphql_postgres/main.rs @@ -4,7 +4,7 @@ #![cfg(all(feature = "graphql", feature = "postgres"))] use async_graphql::Request; -use distributed::graphql::{select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions}; use distributed::microsvc::{Session, ROLE_KEY}; use distributed::ReadModel; use serde::{Deserialize, Serialize}; @@ -48,7 +48,7 @@ async fn postgres_list_query_when_database_url_set() { let engine = GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .build() .expect("build"); diff --git a/tests/graphql_sqlite/main.rs b/tests/graphql_sqlite/main.rs index d6241354..9704d6e3 100644 --- a/tests/graphql_sqlite/main.rs +++ b/tests/graphql_sqlite/main.rs @@ -4,7 +4,7 @@ use async_graphql::Request; use distributed::{ - graphql::{col, rel, select, GraphqlEngine, ModelPermissions}, + graphql::{col, rel, read, GraphqlEngine, ModelPermissions}, microsvc::Session, ColumnType, ForeignKey, PrimaryKey, ReadModel, RelationshipDef, RelationshipKind, TableColumn, TableKind, TableSchema, ROLE_KEY, USER_ID_KEY, @@ -172,14 +172,12 @@ async fn m2m_permission_filter_resolves_through_field_names_to_columns() { let engine = GraphqlEngine::builder(pool) .table_schema(m2m_link_schema()) .model::( - ModelPermissions::new().role( - "user", - select() + ModelPermissions::new().grant("user", read() .all_columns() - .filter(rel("weapons", col("weapon_id").eq("w1"))), + .rows(rel("weapons", col("weapon_id").eq("w1"))), ), ) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .roles(&["user"]) .build() .expect("build"); @@ -196,6 +194,54 @@ async fn m2m_permission_filter_resolves_through_field_names_to_columns() { assert_eq!(players[0]["player_id"], "p1"); } +/// Client `where` with m2m relationship predicate (EXISTS through join table). +#[tokio::test] +async fn m2m_client_where_relationship_predicate() { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + for sql in [ + "CREATE TABLE m2m_players (player_id TEXT PRIMARY KEY, name TEXT NOT NULL)", + "CREATE TABLE m2m_weapons (weapon_id TEXT PRIMARY KEY, name TEXT NOT NULL)", + "CREATE TABLE m2m_player_weapon_links (player_ref TEXT NOT NULL, weapon_ref TEXT NOT NULL)", + "INSERT INTO m2m_players VALUES ('p1', 'Ada'), ('p2', 'Grace'), ('p3', 'Both')", + "INSERT INTO m2m_weapons VALUES ('w1', 'Compiler'), ('w2', 'Debugger')", + "INSERT INTO m2m_player_weapon_links VALUES ('p1', 'w1'), ('p2', 'w2'), ('p3', 'w1'), ('p3', 'w2')", + ] { + sqlx::query(sql).execute(&pool).await.unwrap(); + } + + let engine = GraphqlEngine::builder(pool) + .table_schema(m2m_link_schema()) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) + .roles(&["user"]) + .build() + .expect("build"); + + let session = session_role("user", "u1"); + let resp = engine + .execute( + &session, + Request::new( + r#"{ m2m_players(where: { weapons: { weapon_id: { _eq: "w1" } } }) { player_id name } }"#, + ), + ) + .await; + assert!(!resp.is_err(), "{:?}", resp.errors); + let data = serde_json::to_value(&resp.data).unwrap(); + let players = data["m2m_players"].as_array().expect("players"); + let ids: Vec<&str> = players + .iter() + .map(|p| p["player_id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&"p1"), "p1 has w1: {data}"); + assert!(ids.contains(&"p3"), "p3 has w1: {data}"); + assert!(!ids.contains(&"p2"), "p2 only has w2: {data}"); + assert_eq!(ids.len(), 2, "{data}"); +} + fn parent_schema() -> TableSchema { TableSchema { model_name: "ParentView".into(), diff --git a/tests/graphql_subscriptions_sqlite/main.rs b/tests/graphql_subscriptions_sqlite/main.rs index 50c6e60c..919b0c2f 100644 --- a/tests/graphql_subscriptions_sqlite/main.rs +++ b/tests/graphql_subscriptions_sqlite/main.rs @@ -5,8 +5,8 @@ use std::time::Duration; use async_graphql::Request; -use distributed::graphql::GraphqlEngine; -use distributed::microsvc::{Session, ROLE_KEY}; +use distributed::graphql::{claim, col, read, GraphqlEngine, ModelPermissions}; +use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; use distributed::{ ColumnType, ExpectedVersion, PrimaryKey, ReadModelChange, ReadModelWritePlanStore, RowKey, RowValue, RowValues, RowWriteMode, TableColumn, TableKind, TableMutation, TableRowMutation, @@ -226,3 +226,135 @@ fn response_hash_stable() { distributed::graphql::subscribe::response_hash(&c) ); } + +/// Live stream re-exec must AND claim row filters (same as query path). +/// Two tenants subscribe with the same document; each only ever sees own rows +/// on the initial push and after a cross-tenant write. +#[tokio::test] +async fn subscription_claim_isolation_across_tenants() { + use distributed::{ReadModel, RelationalReadModel}; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] + #[table("notes")] + struct NoteView { + #[id("note_id")] + note_id: String, + owner_id: String, + body: String, + } + + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query( + "CREATE TABLE notes ( + note_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + body TEXT NOT NULL, + _sourced_version INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO notes (note_id, owner_id, body, _sourced_version) VALUES + ('n-a1', 'tenant-a', 'a1', 1), + ('n-b1', 'tenant-b', 'b1', 1);", + ) + .execute(&pool) + .await + .unwrap(); + + let repo = distributed::SqliteRepository::new(pool.clone()); + let change_rx = repo.read_model_changes(); + + let engine = GraphqlEngine::builder(pool.clone()) + .roles(&["user"]) + .model::( + ModelPermissions::new().grant( + "user", + read() + .all_columns() + .rows(col("owner_id").eq(claim("x-user-id"))), + ), + ) + .change_stream(change_rx) + .build() + .expect("build"); + + fn tenant_session(tenant: &str) -> Session { + let mut s = Session::new(); + s.set(ROLE_KEY, "user"); + s.set(USER_ID_KEY, tenant); + s + } + + let sub_doc = r#"subscription { notes { note_id owner_id body } }"#; + + let mut stream_a = Box::pin(engine.execute_stream( + &tenant_session("tenant-a"), + Request::new(sub_doc), + )); + let mut stream_b = Box::pin(engine.execute_stream( + &tenant_session("tenant-b"), + Request::new(sub_doc), + )); + + let first_a = tokio::time::timeout(Duration::from_secs(2), stream_a.next()) + .await + .expect("timeout A") + .expect("stream A ended"); + assert!(!first_a.is_err(), "{:?}", first_a.errors); + let data_a = serde_json::to_value(&first_a.data).unwrap(); + let notes_a = data_a["notes"].as_array().expect("notes A"); + assert_eq!(notes_a.len(), 1, "A must only see own row: {data_a}"); + assert_eq!(notes_a[0]["owner_id"], "tenant-a"); + assert_eq!(notes_a[0]["note_id"], "n-a1"); + + let first_b = tokio::time::timeout(Duration::from_secs(2), stream_b.next()) + .await + .expect("timeout B") + .expect("stream B ended"); + assert!(!first_b.is_err(), "{:?}", first_b.errors); + let data_b = serde_json::to_value(&first_b.data).unwrap(); + let notes_b = data_b["notes"].as_array().expect("notes B"); + assert_eq!(notes_b.len(), 1, "B must only see own row: {data_b}"); + assert_eq!(notes_b[0]["owner_id"], "tenant-b"); + + // Commit a new row for A — only stream A may receive it. + let schema = NoteView::schema(); + let mut values = RowValues::new(); + values.insert("note_id", RowValue::String("n-a2".into())); + values.insert("owner_id", RowValue::String("tenant-a".into())); + values.insert("body", RowValue::String("a2".into())); + let plan = TableWritePlan::new(vec![TableMutation::UpsertRow(TableRowMutation { + schema, + key: RowKey::new([("note_id", RowValue::String("n-a2".into()))]), + values, + expected_version: ExpectedVersion::Any, + mode: RowWriteMode::Upsert, + })]); + repo.commit_write_plan(plan).await.unwrap(); + + let push_a = tokio::time::timeout(Duration::from_secs(2), stream_a.next()) + .await + .expect("timeout A push") + .expect("stream A ended"); + assert!(!push_a.is_err(), "{:?}", push_a.errors); + let data_a2 = serde_json::to_value(&push_a.data).unwrap(); + let notes_a2 = data_a2["notes"].as_array().unwrap(); + assert_eq!(notes_a2.len(), 2, "A sees both own notes: {data_a2}"); + assert!( + notes_a2.iter().all(|n| n["owner_id"] == "tenant-a"), + "A stream leaked foreign owner: {data_a2}" + ); + + // B's payload is unchanged (still one row) — hash gate may suppress a push. + // If a push arrives, it must still be only tenant-b. + if let Ok(Some(push_b)) = tokio::time::timeout(Duration::from_millis(500), stream_b.next()).await + { + assert!(!push_b.is_err(), "{:?}", push_b.errors); + let data_b2 = serde_json::to_value(&push_b.data).unwrap(); + let notes_b2 = data_b2["notes"].as_array().unwrap(); + assert_eq!(notes_b2.len(), 1, "B must not see A's insert: {data_b2}"); + assert_eq!(notes_b2[0]["owner_id"], "tenant-b"); + } +} diff --git a/tests/support/graphql.rs b/tests/support/graphql.rs index 029c4dd0..64410f4c 100644 --- a/tests/support/graphql.rs +++ b/tests/support/graphql.rs @@ -5,7 +5,7 @@ #![allow(dead_code)] // each including target uses a subset use async_graphql::Request; -use distributed::graphql::{select, GraphqlEngine, ModelPermissions}; +use distributed::graphql::{read, GraphqlEngine, ModelPermissions}; use distributed::microsvc::{Session, ROLE_KEY, USER_ID_KEY}; use distributed::ReadModel; use serde::{Deserialize, Serialize}; @@ -75,7 +75,7 @@ pub async fn seed_orders() -> sqlx::SqlitePool { pub fn engine_all_columns(pool: sqlx::SqlitePool) -> GraphqlEngine { GraphqlEngine::builder(pool) .roles(&["user"]) - .model::(ModelPermissions::new().role("user", select().all_columns())) + .model::(ModelPermissions::new().grant("user", read().all_columns())) .build() .unwrap() }