Skip to content
Closed
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
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1420,21 +1420,19 @@ 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};

let engine = GraphqlEngine::from_manifest(&manifest, pool)?
.roles(&["user", "admin", "anonymous"])
.model::<TodoView>(
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()
Expand Down Expand Up @@ -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`
Expand Down
8 changes: 4 additions & 4 deletions distributed_cli/skills/distributed-graphql/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,17 @@ src/query/
2. Add `src/query/<table>.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<OrderView> {
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"]))
}
```

Expand Down
6 changes: 3 additions & 3 deletions distributed_cli/src/generate/service_crate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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,
Expand Down
23 changes: 17 additions & 6 deletions docs/graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down Expand Up @@ -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
Expand All @@ -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/*`.
15 changes: 15 additions & 0 deletions docs/pre-release-surface-ir-and-client-cache.md
Original file line number Diff line number Diff line change
@@ -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**.
30 changes: 15 additions & 15 deletions src/graphql/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<BindValue>,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<BindValue>,
bytes_paths: &mut Vec<String>,
Expand Down Expand Up @@ -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<BindValue>,
bytes_paths: &mut Vec<String>,
Expand Down Expand Up @@ -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<BindValue>,
bytes_paths: &mut Vec<String>,
Expand Down Expand Up @@ -1068,7 +1068,7 @@ fn compile_order_by(
schema: &TableSchema,
order_arg: Option<&Value>,
alias: &str,
perm: &SelectPermission,
perm: &ReadPermission,
strict: bool,
) -> Result<String, String> {
let mut parts = Vec::new();
Expand Down Expand Up @@ -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<BindValue>,
Expand All @@ -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,
)?);
Expand Down Expand Up @@ -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<BindValue>,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}");
Expand All @@ -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}");
Expand All @@ -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}");
Expand All @@ -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}");
Expand Down
10 changes: 5 additions & 5 deletions src/graphql/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -81,7 +81,7 @@ pub(crate) struct CatalogEntry {

#[derive(Clone)]
pub(crate) struct RoleModelPerm {
pub permission: SelectPermission,
pub permission: ReadPermission,
}

pub(crate) struct EngineInner {
Expand Down Expand Up @@ -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(_) => {
Expand All @@ -443,7 +443,7 @@ impl GraphqlEngineBuilder {
pub fn permission<M: RelationalReadModelIncludes>(
mut self,
role: &str,
p: SelectPermission,
p: ReadPermission,
) -> Self {
let model = M::schema().model_name.clone();
if !self.catalog.contains_key(&model) {
Expand Down Expand Up @@ -591,7 +591,7 @@ impl GraphqlEngineBuilder {
}
}

if let Some(filter) = &perm.filter {
if let Some(filter) = &perm.row_filter {
validate_filter(
filter,
&entry.schema,
Expand Down
2 changes: 1 addition & 1 deletion src/graphql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading
Loading