@loradb/lora-graphql 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -77
- package/dist/analyze/access.d.ts +32 -0
- package/dist/analyze/lint.d.ts +9 -0
- package/dist/cli.js +310 -286
- package/dist/cli.js.map +1 -1
- package/dist/compile/auth.d.ts +64 -2
- package/dist/compile/cache.d.ts +37 -0
- package/dist/compile/context.d.ts +12 -0
- package/dist/compile/cypher.d.ts +5 -0
- package/dist/compile/read.d.ts +6 -0
- package/dist/{diff-fpWH5KUa.js → diff-DUOLUgrI.js} +2 -2
- package/dist/{diff-fpWH5KUa.js.map → diff-DUOLUgrI.js.map} +1 -1
- package/dist/driver-C7a5NkfH.js +11521 -0
- package/dist/driver-C7a5NkfH.js.map +1 -0
- package/dist/driver.d.ts +17 -8
- package/dist/errors.d.ts +1 -1
- package/dist/execute/feed.d.ts +1 -1
- package/dist/guards.d.ts +13 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/lora-graphql.d.ts +37 -5
- package/dist/model/desugar.d.ts +34 -0
- package/dist/model/directives.d.ts +1 -1
- package/dist/model/positions.d.ts +22 -0
- package/dist/model/types.d.ts +38 -1
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +145 -28
- package/dist/testing.js.map +1 -1
- package/package.json +4 -3
- package/dist/driver-CQj9gkvl.js +0 -9580
- package/dist/driver-CQj9gkvl.js.map +0 -1
package/README.md
CHANGED
|
@@ -101,47 +101,55 @@ and only by the operators listed; sortable only with `@sortable`;
|
|
|
101
101
|
|
|
102
102
|
## Directives
|
|
103
103
|
|
|
104
|
+
A directive applies where the tables below say, and nowhere else: one in
|
|
105
|
+
a position the model would not apply (an `@authorization` on an interface
|
|
106
|
+
field, a `@selectable` on a field of an object type without `@node`, a
|
|
107
|
+
`@limit` on a scalar field) is a model error naming the directive and the
|
|
108
|
+
position, never silently ignored. `DIRECTIVE_POSITIONS` in
|
|
109
|
+
`src/model/positions.ts` is the full table.
|
|
110
|
+
|
|
104
111
|
Model:
|
|
105
112
|
|
|
106
|
-
| Directive | On | Meaning
|
|
107
|
-
| ---------------------------------------------------------------------------------------------------------- | ----------------- |
|
|
108
|
-
| `@node(labels:, plural:)` | type | A node label set; default: the type name
|
|
109
|
-
| `@key(generate:)` | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. `generate: true` fills a UUID on create
|
|
110
|
-
| `@unique` | field | Uniqueness constraint
|
|
111
|
-
| `@index(kind: RANGE \| TEXT \| POINT)` | field | An explicit index, usually inferred
|
|
112
|
-
| `@storedAs(type:)` | custom scalar | How a custom scalar is stored (`STRING`, `INT`, `FLOAT`, `BOOLEAN`, `DATETIME`, `DATE`); its SDL description is what clients see, whatever implementation `scalars` passes
|
|
113
|
-
| `@relationship(type:, direction:, properties:, queryDirection:, onDelete:, nestedOperations:, aggregate:)` | field | An edge to a `@node` type, interface or union. `queryDirection: UNDIRECTED` reads both ways; `onDelete: DETACH \| CASCADE \| RESTRICT`; `nestedOperations` lists the nested writes inputs offer; `aggregate: false` drops its aggregates
|
|
114
|
-
| `@declareRelationship` | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface
|
|
115
|
-
| `@relationshipProperties` | type | Properties on a relationship type
|
|
116
|
-
| `@alias(property:)` | field | API name differs from the stored property
|
|
117
|
-
| `@private` | field | Stored, never exposed
|
|
118
|
-
| `@readonly` | field | Exposed, never client-settable; on a relationship, absent from create and update inputs
|
|
119
|
-
| `@settable(onCreate:, onUpdate:)` | field | Which mutations may set it, e.g. set once on create; on a relationship, whether the inputs offer it (an upsert of an existing node keeps it)
|
|
120
|
-
| `@selectable(onRead:, onAggregate:)` | field | `onRead: false` makes a field write-only
|
|
121
|
-
| `@default(value:)` | field | Stored on create when the input omits it; on a relationship property, when the relationship is created
|
|
122
|
-
| `@timestamp(operations: [CREATE, UPDATE])` | field | Set to the current time; never client-settable. On a relationship property: CREATE when the relationship is created, UPDATE on edge updates and re-connects that set properties
|
|
123
|
-
| `@populatedBy(callback:, operations:)` | field | Computed by a named callback on write
|
|
124
|
-
| `@cardinality(max:)` | list relationship | Declared fan-out, for cost estimates
|
|
125
|
-
| `@cypher(statement:, columnName:)` | field | A field backed by a Cypher statement. Returns scalars, `@node` types, interfaces or unions over them, or object types without `@node` (read from a map)
|
|
126
|
-
| `@fulltext(indexes: [{ name, fields, analyzer, queryName }])` | type | FULLTEXT indexes, each with a search root field
|
|
127
|
-
| `@vector(dimensions:, similarity:, queryName:)` | `[Float!]` field | A VECTOR index and a similarity root field
|
|
128
|
-
| `@plural(value:)` | interface, union | The root field's name
|
|
113
|
+
| Directive | On | Meaning |
|
|
114
|
+
| ---------------------------------------------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
115
|
+
| `@node(labels:, plural:)` | type | A node label set; default: the type name |
|
|
116
|
+
| `@key(generate:)` | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. `generate: true` fills a UUID on create |
|
|
117
|
+
| `@unique` | field | Uniqueness constraint |
|
|
118
|
+
| `@index(kind: RANGE \| TEXT \| POINT)` | field | An explicit index, usually inferred |
|
|
119
|
+
| `@storedAs(type:)` | custom scalar | How a custom scalar is stored (`STRING`, `INT`, `FLOAT`, `BOOLEAN`, `DATETIME`, `DATE`); its SDL description is what clients see, whatever implementation `scalars` passes |
|
|
120
|
+
| `@relationship(type:, direction:, properties:, queryDirection:, onDelete:, nestedOperations:, aggregate:)` | field | An edge to a `@node` type, interface or union. `queryDirection: UNDIRECTED` reads both ways; `onDelete: DETACH \| CASCADE \| RESTRICT`; `nestedOperations` lists the nested writes inputs offer; `aggregate: false` drops its aggregates |
|
|
121
|
+
| `@declareRelationship` | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface |
|
|
122
|
+
| `@relationshipProperties` | type | Properties on a relationship type |
|
|
123
|
+
| `@alias(property:)` | field | API name differs from the stored property |
|
|
124
|
+
| `@private` | field | Stored, never exposed |
|
|
125
|
+
| `@readonly` | field | Exposed, never client-settable; on a relationship, absent from create and update inputs |
|
|
126
|
+
| `@settable(onCreate:, onUpdate:)` | field | Which mutations may set it, e.g. set once on create; on a relationship, whether the inputs offer it (an upsert of an existing node keeps it); on a relationship property, `onUpdate: false` also refuses a re-connect that would change it |
|
|
127
|
+
| `@selectable(onRead:, onAggregate:)` | field | `onRead: false` makes a field write-only; on a relationship property, it leaves the edge type too (`onAggregate: false`, the edge aggregates) |
|
|
128
|
+
| `@default(value:)` | field | Stored on create when the input omits it; on a relationship property, when the relationship is created |
|
|
129
|
+
| `@timestamp(operations: [CREATE, UPDATE])` | field | Set to the current time; never client-settable. On a relationship property: CREATE when the relationship is created, UPDATE on edge updates and re-connects that set properties |
|
|
130
|
+
| `@populatedBy(callback:, operations:)` | field | Computed by a named callback on write |
|
|
131
|
+
| `@cardinality(max:)` | list relationship | Declared fan-out, for cost estimates |
|
|
132
|
+
| `@cypher(statement:, columnName:)` | field | A field backed by a Cypher statement. Returns scalars, `@node` types, interfaces or unions over them, or object types without `@node` (read from a map) |
|
|
133
|
+
| `@fulltext(indexes: [{ name, fields, analyzer, queryName }])` | type | FULLTEXT indexes, each with a search root field |
|
|
134
|
+
| `@vector(dimensions:, similarity:, queryName:)` | `[Float!]` field | A VECTOR index and a similarity root field |
|
|
135
|
+
| `@plural(value:)` | interface, union | The root field's name |
|
|
129
136
|
|
|
130
137
|
API:
|
|
131
138
|
|
|
132
|
-
| Directive | On | Meaning
|
|
133
|
-
| ----------------------------------------------------- | -------------------------------------------- |
|
|
134
|
-
| `@query(read:, aggregate:)` | type, interface, union | Generated reads
|
|
135
|
-
| `@mutation(operations: [CREATE, UPDATE, DELETE])` | type | Generated mutations; none without it
|
|
136
|
-
| `@subscription(operations: [CREATE, UPDATE, DELETE])` | type | Generated subscriptions; none without it
|
|
137
|
-
| `@filterable(byValue: [...])` | field | Filter operators. Bare: `EQ` and `IN` (lists: `INCLUDES`). On a relationship: enables relationship filters
|
|
138
|
-
| `@sortable` | field | Sort and paginate by this field (on a relationship property: `sort: [{ edge: { ... } }]`)
|
|
139
|
-
| `@groupBy` | field | A grouping key of `<plural>Grouped(by:)` (needs `@query(aggregate: true)`)
|
|
140
|
-
| `@limit(default:, max:)` | type, interface, union, list relationship | Page size bounds
|
|
141
|
-
| `@relayId` | `@key` field | Adds a global `id` and the `Node` interface
|
|
142
|
-
| `@authentication(operations:, jwt:)` | type, field | Needs an authenticated request, whose claims satisfy `jwt`
|
|
143
|
-
| `@authorization(filter:, validate:)` | type (filter and validate), field (validate) | Row-level rules, compiled into statements
|
|
144
|
-
| `@jwt`, `@jwtClaim(path:)` | type, field | The claims shape; rules may only use declared claims
|
|
139
|
+
| Directive | On | Meaning |
|
|
140
|
+
| ----------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
141
|
+
| `@query(read:, aggregate:)` | type, interface, union | Generated reads |
|
|
142
|
+
| `@mutation(operations: [CREATE, UPDATE, DELETE])` | type | Generated mutations; none without it |
|
|
143
|
+
| `@subscription(operations: [CREATE, UPDATE, DELETE])` | type | Generated subscriptions; none without it |
|
|
144
|
+
| `@filterable(byValue: [...])` | field | Filter operators. Bare: `EQ` and `IN` (lists: `INCLUDES`). On a relationship: enables relationship filters |
|
|
145
|
+
| `@sortable` | field | Sort and paginate by this field (on a relationship property: `sort: [{ edge: { ... } }]`) |
|
|
146
|
+
| `@groupBy` | field | A grouping key of `<plural>Grouped(by:)` (needs `@query(aggregate: true)`) |
|
|
147
|
+
| `@limit(default:, max:)` | type, interface, union, list relationship | Page size bounds |
|
|
148
|
+
| `@relayId` | `@key` field | Adds a global `id` and the `Node` interface |
|
|
149
|
+
| `@authentication(operations:, jwt:)` | type, field | Needs an authenticated request, whose claims satisfy `jwt` |
|
|
150
|
+
| `@authorization(filter:, validate:)` | type (filter and validate), field (validate) | Row-level rules, compiled into statements |
|
|
151
|
+
| `@jwt`, `@jwtClaim(path:)` | type, field | The claims shape; rules may only use declared claims |
|
|
152
|
+
| `@viewer(type:, field:)` | `@jwt` claim | The claim naming the caller's node (by a `@key` or `@unique` field): enables `isViewer` and `viewer` in rules |
|
|
145
153
|
|
|
146
154
|
`directiveTypeDefs` (or `lora-graphql directives`) prints these as SDL for
|
|
147
155
|
editors and codegen.
|
|
@@ -204,8 +212,11 @@ string), `Date`, `Time`, `LocalTime`, `DateTime`, `LocalDateTime`,
|
|
|
204
212
|
properties together.
|
|
205
213
|
- **Absent and `null` filters are left out of the statement**, so a filter
|
|
206
214
|
bound to an unset variable costs nothing. `eq: null` does not mean
|
|
207
|
-
`IS NULL`: use `isNull: true`.
|
|
208
|
-
|
|
215
|
+
`IS NULL`: use `isNull: true`. An `OR` branch left empty that way is left
|
|
216
|
+
out of the `OR`, and an `OR` or `NOT` with nothing left is left out
|
|
217
|
+
entirely, so an unset variable never widens a filter to every row; a
|
|
218
|
+
literal `OR: []` matches nothing. A relationship quantifier whose filter
|
|
219
|
+
is empty is left out too: ask `count: { gt: 0 }` for "has any".
|
|
209
220
|
- `count` and `single` count related nodes once each, however many
|
|
210
221
|
relationships lead to them. `all` holds on an empty set, and a missing
|
|
211
222
|
property fails it.
|
|
@@ -413,7 +424,15 @@ commits:
|
|
|
413
424
|
(`CONSTRAINT_VIOLATION`);
|
|
414
425
|
- `@authorization` validate rules, type and field level (`FORBIDDEN`).
|
|
415
426
|
|
|
416
|
-
Any failure rolls the whole mutation back.
|
|
427
|
+
Any failure rolls the whole mutation back. Atomicity is per root field:
|
|
428
|
+
in an operation with several root fields, each runs in its own
|
|
429
|
+
transaction, so a later failure leaves the earlier ones committed. Pass
|
|
430
|
+
`mutationTransaction: "operation"` to run every root field of a mutation
|
|
431
|
+
in one transaction through `execute()` (persisted operations included):
|
|
432
|
+
it commits only when the operation reports no error, and otherwise rolls
|
|
433
|
+
back and returns `data: null`. With another server, put a `lora.begin()`
|
|
434
|
+
transaction in the context (see [Transactions](#transactions)). Engine
|
|
435
|
+
constraint errors come
|
|
417
436
|
back as `CONSTRAINT_VIOLATION` naming the type and field. A mutation creates
|
|
418
437
|
or deletes at most `maxBatch` nodes (default 1000). `@key` is not updatable.
|
|
419
438
|
`info` reports `nodesCreated`, `nodesUpdated`, `nodesDeleted`,
|
|
@@ -467,7 +486,11 @@ type Event @node @fulltext(indexes: [{ fields: ["title", "summary"] }]) {
|
|
|
467
486
|
```
|
|
468
487
|
|
|
469
488
|
Full-text queries AND their terms, fold case and accents, and treat a
|
|
470
|
-
trailing `*` as a prefix.
|
|
489
|
+
trailing `*` as a prefix. A `[String!]` field in `@fulltext` indexes each
|
|
490
|
+
of its strings. Search connections (`searchEventsConnection`) page with
|
|
491
|
+
cursors and take `totalCount`: every match after `where` and the read
|
|
492
|
+
rules (a vector search counts within its candidate window); a selection of
|
|
493
|
+
only `totalCount` reads no page. Vector search takes a query `vector` or the key of
|
|
471
494
|
a node whose embedding to start from (`to`, which is left out of the
|
|
472
495
|
results). The index returns its top candidates before `where` applies, so
|
|
473
496
|
the library asks it for four times the page when a filter is present.
|
|
@@ -595,17 +618,29 @@ type Post
|
|
|
595
618
|
}
|
|
596
619
|
```
|
|
597
620
|
|
|
598
|
-
- A rule is `{ node, jwt, AND, OR, NOT }`. `node` is a filter over the type
|
|
599
|
-
|
|
621
|
+
- A rule is `{ node, jwt, AND, OR, NOT }`. `node` is a filter over the type
|
|
622
|
+
(relationship properties included, through `<field>Connection: { some:
|
|
623
|
+
{ node, edge } }`), where `"$jwt.path"` strings become the caller's claims and
|
|
600
624
|
`"$context.path"` strings values from the GraphQL context. `jwt` tests
|
|
601
625
|
claims (`eq`, `in`, `includes`, `contains`, `startsWith`, `endsWith`,
|
|
602
626
|
`lt`, `lte`, `gt`, `gte`, `exists`).
|
|
627
|
+
- **The caller's own node.** Mark the claim that identifies the caller
|
|
628
|
+
with `@viewer(type: "Person", field: "subject")` (the field is `@key` or
|
|
629
|
+
`@unique`), so the key can stay a slug while the subject is an identity
|
|
630
|
+
provider's opaque id. Rules then say `{ node: { isViewer: true } }` on
|
|
631
|
+
the viewer type, or `{ node: { author: { isViewer: true } } }` through a
|
|
632
|
+
relationship; this expands to `{ subject: { eq: "$jwt.sub" } }` at
|
|
633
|
+
startup and compiles to exactly the same statement. `viewer: { verified:
|
|
634
|
+
{ eq: true } }` tests the caller's own node: one seek by the claim. Both
|
|
635
|
+
are unknown without the claim, so `NOT { isViewer: true }` never grants a
|
|
636
|
+
signed-out caller. `isViewer` takes `true` only; use `NOT` for the
|
|
637
|
+
opposite.
|
|
603
638
|
- Inside a longer string, write `${jwt.path}` or `${context.path}`:
|
|
604
639
|
`key: { startsWith: "${jwt.sub}:" }` confines a user to keys that begin
|
|
605
640
|
with their `sub` and `:`. The claim must be a string, number or boolean;
|
|
606
641
|
otherwise the rule denies. Pick a separator no `sub` contains: with `-`,
|
|
607
642
|
user `a` could take `a-b-…`, the key space of user `a-b`. See
|
|
608
|
-
[docs/design/graphql-
|
|
643
|
+
[docs/design/graphql-threat-model.md](../../docs/design/graphql-threat-model.md).
|
|
609
644
|
- **Claim tests run in JavaScript at compile time**, so an admin's
|
|
610
645
|
statement carries no filter at all. Statements stay specialised and
|
|
611
646
|
index-friendly.
|
|
@@ -641,10 +676,118 @@ type Post
|
|
|
641
676
|
them as a signed-in caller.
|
|
642
677
|
- `@authentication(operations:, jwt:)` covers `READ`, `CREATE`, `UPDATE`,
|
|
643
678
|
`DELETE`, `CREATE_RELATIONSHIP`, `DELETE_RELATIONSHIP` and `SUBSCRIBE`,
|
|
644
|
-
and may require claims.
|
|
679
|
+
and may require claims. On a relationship field, `CREATE` / `UPDATE`
|
|
680
|
+
cover setting it in a create or update input, `CREATE_RELATIONSHIP` a
|
|
681
|
+
`connect` or nested `create` through it, and `DELETE_RELATIONSHIP` a
|
|
682
|
+
`disconnect` or nested `delete`.
|
|
645
683
|
- Rules are checked against the model at startup: an unknown field,
|
|
646
684
|
operator or (with `@jwt`) claim, or a test that is empty or null, is an
|
|
647
685
|
error, not an open door.
|
|
686
|
+
- **Owner-scoped keys.** `key: String! @key(scope: VIEWER, separator: ":")`
|
|
687
|
+
keeps created keys in the caller's key space: a create (nested creates
|
|
688
|
+
and upsert-creates included) must use a key that starts with the
|
|
689
|
+
`@viewer` claim and the separator, and is longer than that prefix. It
|
|
690
|
+
is checked before any statement runs, so `FORBIDDEN` for `bob:x` reads
|
|
691
|
+
the same whether `bob:x` exists or not. A claim containing the
|
|
692
|
+
separator is refused, so user `a` cannot write into the key space of
|
|
693
|
+
user `a:b`. It needs `@viewer`; the schema's bypass skips it. Keys shared
|
|
694
|
+
by two owners (`f1:lou`) stay hand-written rules.
|
|
695
|
+
- **Masks.** A field-level READ rule fails the row; a mask substitutes a
|
|
696
|
+
value instead:
|
|
697
|
+
|
|
698
|
+
```graphql
|
|
699
|
+
status: ConnectionRequestStatus!
|
|
700
|
+
@authorization(
|
|
701
|
+
mask: [
|
|
702
|
+
{
|
|
703
|
+
unless: {
|
|
704
|
+
OR: [
|
|
705
|
+
{ node: { status: { in: [PENDING, ACCEPTED] } } }
|
|
706
|
+
{ node: { to: { isViewer: true } } }
|
|
707
|
+
]
|
|
708
|
+
}
|
|
709
|
+
value: PENDING
|
|
710
|
+
}
|
|
711
|
+
]
|
|
712
|
+
)
|
|
713
|
+
lastSeenAt: DateTime
|
|
714
|
+
@authorization(mask: [{ unless: { node: { isViewer: true } } }])
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
A row failing `unless` reads the field as `value` (`null` when left
|
|
718
|
+
out, which a non-null field refuses; `value` is type-checked). Filters
|
|
719
|
+
compare the value the reader sees, so a mask never leaks through a
|
|
720
|
+
filter, and such a filter cannot use the field's index. Rules see the
|
|
721
|
+
stored value. Sorting, grouping and aggregating by a masked field are
|
|
722
|
+
refused unless the claims settle the mask (the brief proposed sorting by
|
|
723
|
+
the masked value; refusing keeps order from hinting at hidden values).
|
|
724
|
+
Masks sit on scalar fields of `@node` types other than the `@key`.
|
|
725
|
+
|
|
726
|
+
- **Named rules.** Define a rule once and use it as `{ rule: "name" }`
|
|
727
|
+
wherever a rule part may stand:
|
|
728
|
+
|
|
729
|
+
```graphql
|
|
730
|
+
extend schema
|
|
731
|
+
@authorizationRules(
|
|
732
|
+
rules: [{ name: "admin", where: { jwt: { roles: { includes: "admin" } } } }]
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
type Trip
|
|
736
|
+
@node
|
|
737
|
+
@authorizationRule(
|
|
738
|
+
name: "member"
|
|
739
|
+
where: {
|
|
740
|
+
OR: [
|
|
741
|
+
{ node: { members: { some: { isViewer: true } } } }
|
|
742
|
+
{ node: { owner: { isViewer: true } } }
|
|
743
|
+
]
|
|
744
|
+
}
|
|
745
|
+
)
|
|
746
|
+
@authorization(
|
|
747
|
+
filter: [{ where: { OR: [{ rule: "member" }, { rule: "admin" }] } }]
|
|
748
|
+
) { ... }
|
|
749
|
+
|
|
750
|
+
type PackingItem
|
|
751
|
+
@node
|
|
752
|
+
@authorization(
|
|
753
|
+
filter: [
|
|
754
|
+
{
|
|
755
|
+
where: {
|
|
756
|
+
OR: [{ node: { trip: { rule: "member" } } }, { rule: "admin" }]
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
]
|
|
760
|
+
) { ... }
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
Schema rules (`@authorizationRules`) test claims only. A type's rules
|
|
764
|
+
(`@authorizationRule`, repeatable) are found first, then the schema's;
|
|
765
|
+
inside a node filter (`trip: { rule: "member" }`) the name is a rule of
|
|
766
|
+
that node's type, and that rule must test `node` only. Rules are inlined
|
|
767
|
+
at startup, so they compile to exactly the hand-written statement. An
|
|
768
|
+
unknown name, a type rule shadowing a schema rule, and a cycle (named
|
|
769
|
+
with its chain) are model errors. `bypass` and `mutations` in
|
|
770
|
+
`@authorizationDefaults` may name rules too.
|
|
771
|
+
|
|
772
|
+
- **Schema-wide defaults.**
|
|
773
|
+
|
|
774
|
+
```graphql
|
|
775
|
+
extend schema
|
|
776
|
+
@authorizationDefaults(
|
|
777
|
+
bypass: { jwt: { roles: { includes: "admin" } } }
|
|
778
|
+
mutations: { jwt: { roles: { includes: "editor" } } }
|
|
779
|
+
)
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
A request passing `bypass` skips every filter and validate rule, field
|
|
783
|
+
and relationship rules included; `@authentication` still applies.
|
|
784
|
+
`bypass` tests claims only, so it is decided before the statement is
|
|
785
|
+
built: an admin's statement carries no rule predicate. A type keeps its
|
|
786
|
+
rules for everyone with `@authorization(bypass: false)`. `mutations` is
|
|
787
|
+
the write rule (`CREATE`, `UPDATE`, `DELETE`) of every `@mutation` type
|
|
788
|
+
that declares no rule for those operations; a type's own rules replace
|
|
789
|
+
it, never merge with it. `check()` fails on a `@mutation` type whose
|
|
790
|
+
writes nothing guards, unless it says `@authorization(public: [...])`.
|
|
648
791
|
|
|
649
792
|
Relationship and `@cypher` fields take field-level `@authorization`
|
|
650
793
|
with READ validate rules: a row failing the rule reads the field as
|
|
@@ -659,6 +802,66 @@ relationship and `UPDATE` for one that already exists; `update: { edge }`
|
|
|
659
802
|
checks `UPDATE`. A request the READ rules refuse reads the property as
|
|
660
803
|
`FORBIDDEN` and cannot filter, sort or aggregate by it.
|
|
661
804
|
|
|
805
|
+
### Rules on relationships
|
|
806
|
+
|
|
807
|
+
A relationship field also takes validate rules for the relationship's
|
|
808
|
+
own operations, where both ends are known:
|
|
809
|
+
|
|
810
|
+
```graphql
|
|
811
|
+
type Trip @node @mutation {
|
|
812
|
+
key: String! @key
|
|
813
|
+
owner: Person! @relationship(type: "OWNS", direction: IN)
|
|
814
|
+
members: [Person!]!
|
|
815
|
+
@relationship(type: "MEMBER", direction: IN, properties: "TripInvite")
|
|
816
|
+
@authorization(
|
|
817
|
+
validate: [
|
|
818
|
+
# the owner invites
|
|
819
|
+
{
|
|
820
|
+
operations: [CONNECT]
|
|
821
|
+
where: { source: { owner: { isViewer: true } } }
|
|
822
|
+
}
|
|
823
|
+
# the owner removes anyone; a member removes only themselves
|
|
824
|
+
{
|
|
825
|
+
operations: [DISCONNECT]
|
|
826
|
+
where: {
|
|
827
|
+
OR: [
|
|
828
|
+
{ source: { owner: { isViewer: true } } }
|
|
829
|
+
{ target: { isViewer: true } }
|
|
830
|
+
]
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
# only the member answers their own invitation, and reads its marker
|
|
834
|
+
{
|
|
835
|
+
operations: [UPDATE_EDGE, READ_EDGE]
|
|
836
|
+
where: { target: { isViewer: true } }
|
|
837
|
+
}
|
|
838
|
+
]
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
- `source` is the node declaring the field, `target` the related node and
|
|
844
|
+
`edge` the relationship's properties; `jwt`, `viewer`, `AND`, `OR` and
|
|
845
|
+
`NOT` work as in any rule. A `node` part is a model error here, and so
|
|
846
|
+
is a relationship operation on a type or scalar field, or in the same
|
|
847
|
+
rule as `READ`.
|
|
848
|
+
- `CONNECT` covers a new relationship (connect, nested create);
|
|
849
|
+
`DISCONNECT` a disconnect, including replacing a single relationship;
|
|
850
|
+
`UPDATE_EDGE` `update: [{ edge }]` and a re-connect that sets
|
|
851
|
+
properties; `READ_EDGE` reading the properties. `CONNECT` and
|
|
852
|
+
`UPDATE_EDGE` are checked on the relationship after the write and
|
|
853
|
+
`DISCONNECT` before it, in the mutation's transaction: a failure is
|
|
854
|
+
`FORBIDDEN` and rolls the mutation back.
|
|
855
|
+
- The rules hold whichever side the write comes from: a connect through
|
|
856
|
+
`Person.trips` (the same relationship type, the other direction)
|
|
857
|
+
answers to `Trip.members`' rules, with source and target as declared on
|
|
858
|
+
`Trip.members`. If both fields carry rules, both apply.
|
|
859
|
+
- A relationship failing `READ_EDGE` reads its properties as `FORBIDDEN`.
|
|
860
|
+
Unless the claims alone settle the rule, nothing may filter, sort or
|
|
861
|
+
aggregate by that relationship's properties.
|
|
862
|
+
- Deleting a node removes its relationships without `DISCONNECT` rules:
|
|
863
|
+
who may delete the node is the type's `DELETE` rule.
|
|
864
|
+
|
|
662
865
|
```graphql
|
|
663
866
|
type Membership @relationshipProperties {
|
|
664
867
|
role: String
|
|
@@ -859,9 +1062,25 @@ files, and exits non-zero on any finding. It is the CI gate. Options:
|
|
|
859
1062
|
- `--database dir [--name app]`: check an existing database as it is, and
|
|
860
1063
|
report indexes it has that the API does not use.
|
|
861
1064
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1065
|
+
`access` prints who may do what: for every type and guarded field, each
|
|
1066
|
+
operation as each kind of caller (anonymous, authenticated, and each role
|
|
1067
|
+
the rules test, such as `roles:admin`), with the verdict (`allowed`,
|
|
1068
|
+
`filtered`, `validated`, `masked`, `denied`, `unauthenticated`) and the
|
|
1069
|
+
rules that decide it. `lora.accessMatrix()` returns the same list; its
|
|
1070
|
+
order is stable, so a snapshot in CI turns access changes into diffs.
|
|
1071
|
+
|
|
1072
|
+
`check` lints authorization too: a filter rule every signed-in caller
|
|
1073
|
+
passes, a rule whose default `requireAuthentication` refuses anonymous
|
|
1074
|
+
callers a branch that needs no claims, and field rules the schema's
|
|
1075
|
+
bypass skips.
|
|
1076
|
+
|
|
1077
|
+
A `@mutation` type with a generated write no rule guards (no
|
|
1078
|
+
`@authentication` or `@authorization` rule for it, and no
|
|
1079
|
+
`@authorizationDefaults(mutations:)` default) fails the run: declare it
|
|
1080
|
+
with `@authorization(public: [CREATE, ...])` when every caller may make
|
|
1081
|
+
it. It also prints lint notes that do not fail the run: `CASE_INSENSITIVE`
|
|
1082
|
+
and `IS_NULL` filters (no index applies), and list relationships without
|
|
1083
|
+
`@cardinality` or statistics.
|
|
865
1084
|
|
|
866
1085
|
`compile` validates persisted operations (`.graphql` files, one entry per
|
|
867
1086
|
operation, or a JSON map of id to source) into `manifest.json`, which
|
|
@@ -912,12 +1131,37 @@ asserted, runs the seed, and records every statement in `t.statements`.
|
|
|
912
1131
|
the query uses the index access it was compiled for (`rowBudget` too).
|
|
913
1132
|
Both need `@loradb/lora-node` and work with any test runner.
|
|
914
1133
|
|
|
1134
|
+
`expectAccess` checks who may do what, against the database:
|
|
1135
|
+
|
|
1136
|
+
```ts
|
|
1137
|
+
await expectAccess(t, {
|
|
1138
|
+
as: { sub: "lou", roles: [] },
|
|
1139
|
+
allowed: [
|
|
1140
|
+
"read Trip lou:tomorrowland",
|
|
1141
|
+
'update Person lou {"name": "Lou"}',
|
|
1142
|
+
"connect Trip.members lou:tomorrowland → f1",
|
|
1143
|
+
],
|
|
1144
|
+
denied: ['update-edge Trip.members lou:tomorrowland → f1 {"rsvp": "GOING"}'],
|
|
1145
|
+
});
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
Entries are `read`, `create`, `update` or `delete` a `Type key`, and
|
|
1149
|
+
`connect`, `disconnect` or `update-edge` a `Type.field key → key`, each
|
|
1150
|
+
with optional input as JSON. Every entry runs as the caller in a
|
|
1151
|
+
transaction that is rolled back, so probes leave no trace. Denied means
|
|
1152
|
+
`FORBIDDEN`, `UNAUTHENTICATED`, `NOT_FOUND` or (for `read`) not visible;
|
|
1153
|
+
any other error is a mismatch either way. All mismatches are reported at
|
|
1154
|
+
once.
|
|
1155
|
+
|
|
915
1156
|
## Drivers, limits and errors
|
|
916
1157
|
|
|
917
1158
|
`loraDriver(db)` adapts a `Database` from `@loradb/lora-node` or
|
|
918
|
-
`@loradb/lora-wasm`.
|
|
919
|
-
|
|
920
|
-
|
|
1159
|
+
`@loradb/lora-wasm`. A read of one statement runs with `execute()` (with
|
|
1160
|
+
lora-node, on a libuv worker, not the JavaScript thread), except a lookup
|
|
1161
|
+
by `@key` that selects only stored fields, which streams its single row
|
|
1162
|
+
synchronously; a read of several statements runs in one read-only
|
|
1163
|
+
transaction. Mutations need interactive transactions, which only the Node
|
|
1164
|
+
binding has, so the WASM binding serves reads.
|
|
921
1165
|
`explain()`, and so plan checks, need the Node binding too.
|
|
922
1166
|
|
|
923
1167
|
| Option | Default | Meaning |
|
|
@@ -944,8 +1188,10 @@ which only the Node binding has, so the WASM binding serves reads.
|
|
|
944
1188
|
Errors carry `extensions.code`: `BAD_USER_INPUT`, `INVALID_CURSOR`,
|
|
945
1189
|
`LIMIT_EXCEEDED`, `COST_EXCEEDED`, `UNAUTHENTICATED`, `FORBIDDEN`,
|
|
946
1190
|
`NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`),
|
|
947
|
-
`DATABASE_ERROR` (with an `id`, also given to `onError`)
|
|
948
|
-
`PERSISTED_QUERY_ONLY` (`execute()` or `subscribe()` got a document under
|
|
1191
|
+
`DATABASE_ERROR` (with an `id`, also given to `onError`),
|
|
1192
|
+
`PERSISTED_QUERY_ONLY` (`execute()` or `subscribe()` got a document under
|
|
1193
|
+
`persistedOnly`) and `WRONG_OPERATION_TYPE` (`execute()` got a
|
|
1194
|
+
subscription, or `subscribe()` a query or mutation). An invalid SDL
|
|
949
1195
|
throws one `ModelError` listing every problem, each located by type and
|
|
950
1196
|
field.
|
|
951
1197
|
|
|
@@ -956,13 +1202,14 @@ the document before that. `execute()`, `subscribe()` and `persist()` apply them,
|
|
|
956
1202
|
`lora.validationRules()` / `lora.envelopPlugin()` bring them to any other
|
|
957
1203
|
server (GraphQL Yoga takes the plugin as is):
|
|
958
1204
|
|
|
959
|
-
| Guard
|
|
960
|
-
|
|
|
961
|
-
| `maxDepth`
|
|
962
|
-
| `
|
|
963
|
-
| `
|
|
964
|
-
| `
|
|
965
|
-
| `
|
|
1205
|
+
| Guard | Default | Limit |
|
|
1206
|
+
| ----------------------- | ---------- | ---------------------------------------- |
|
|
1207
|
+
| `maxDepth` | 12 | Field nesting, through fragments |
|
|
1208
|
+
| `maxIntrospectionDepth` | 20 | Nesting under `__schema` / `__type` |
|
|
1209
|
+
| `maxAliases` | 30 | Aliased fields per document |
|
|
1210
|
+
| `maxRootFields` | 20 | Root fields per operation |
|
|
1211
|
+
| `maxTokens` | 5000 | Lexer tokens per document, while parsing |
|
|
1212
|
+
| `introspection` | production | Off when `NODE_ENV` is `production` |
|
|
966
1213
|
|
|
967
1214
|
With `NODE_ENV=production`, database errors are masked and introspection
|
|
968
1215
|
is off unless configured otherwise. See
|
|
@@ -1017,7 +1264,7 @@ Measured on LoraDB 0.15 over 20 000 festivals and 100 000 relationships
|
|
|
1017
1264
|
|
|
1018
1265
|
| Rule | Why |
|
|
1019
1266
|
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
|
|
1020
|
-
| Relationship filters as `size([… \| 1])`; aggregates of related values with `reduce` | No `OPTIONAL MATCH`;
|
|
1267
|
+
| Relationship filters as `size([… \| 1])`; aggregates of related values with `reduce` | No `OPTIONAL MATCH`; aggregates nest safely |
|
|
1021
1268
|
| `CALL { }` for nested lists, nested connections, `@cypher` and interface members | The only way to sort, limit and aggregate per parent |
|
|
1022
1269
|
| Ordered by an always-present string: `WHERE s >= ""` | The planner walks the index in order and stops at the limit: 0.03 ms instead of 7 ms |
|
|
1023
1270
|
| Mutation statements seek the key in their own `MATCH`, then expand | The plan no longer depends on the optimizer finding the seek: 0.06 ms per connect |
|
|
@@ -1029,22 +1276,22 @@ Measured on LoraDB 0.15 over 20 000 festivals and 100 000 relationships
|
|
|
1029
1276
|
|
|
1030
1277
|
## Coming from @neo4j/graphql
|
|
1031
1278
|
|
|
1032
|
-
| @neo4j/graphql | lora-graphql
|
|
1033
|
-
| ----------------------------------------------------------- |
|
|
1034
|
-
| Every type gets every operation | Reads by default; mutations and subscriptions opt in
|
|
1035
|
-
| Every field filterable by every operator | `@filterable(byValue:)`, checked against the type
|
|
1036
|
-
| Offset cursors (`arrayconnection:N`) | Keyset cursors tagged with their sort (signed with a secret)
|
|
1037
|
-
| `update`/`delete` with an optional `where` | By `@key`, or bulk with a required, bounded `where`
|
|
1038
|
-
| `connect: { where }`, silently a no-op when nothing matches | Connect by key; a missing target is `NOT_FOUND`
|
|
1039
|
-
| Single relationships not enforced; required ones refused | Enforced from both sides; required ones supported
|
|
1040
|
-
| `{ viewers: { add: 1 } }` | `adjust: { viewers: { add: 1 } }`
|
|
1041
|
-
| `@id`, `@populatedBy`, `@timestamp` | `@key(generate: true)`, `@populatedBy`, `@timestamp`
|
|
1042
|
-
| `@authorization` rules evaluated in Cypher | Claim checks folded in JavaScript; node rules compiled
|
|
1043
|
-
| You pick indexes | Inferred from the API, and verified with `explain()`
|
|
1044
|
-
| Unbounded lists; complexity left to you | Every list bounded; a cost limit per operation
|
|
1045
|
-
| Subscriptions over CDC | `@subscription`, from library-made writes
|
|
1046
|
-
| Interfaces and unions with `UNION` | Per-member subqueries merged by sort, each using its own indexes
|
|
1047
|
-
| Federation | Not supported
|
|
1279
|
+
| @neo4j/graphql | lora-graphql |
|
|
1280
|
+
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
1281
|
+
| Every type gets every operation | Reads by default; mutations and subscriptions opt in |
|
|
1282
|
+
| Every field filterable by every operator | `@filterable(byValue:)`, checked against the type |
|
|
1283
|
+
| Offset cursors (`arrayconnection:N`) | Keyset cursors tagged with their sort (signed with a secret) |
|
|
1284
|
+
| `update`/`delete` with an optional `where` | By `@key`, or bulk with a required, bounded `where` |
|
|
1285
|
+
| `connect: { where }`, silently a no-op when nothing matches | Connect by key; a missing target is `NOT_FOUND` |
|
|
1286
|
+
| Single relationships not enforced; required ones refused | Enforced from both sides; required ones supported |
|
|
1287
|
+
| `{ viewers: { add: 1 } }` | `adjust: { viewers: { add: 1 } }` |
|
|
1288
|
+
| `@id`, `@populatedBy`, `@timestamp` | `@key(generate: true)`, `@populatedBy`, `@timestamp` |
|
|
1289
|
+
| `@authorization` rules evaluated in Cypher | Claim checks folded in JavaScript; node rules compiled |
|
|
1290
|
+
| You pick indexes | Inferred from the API, and verified with `explain()` |
|
|
1291
|
+
| Unbounded lists; complexity left to you | Every list bounded; a cost limit per operation |
|
|
1292
|
+
| Subscriptions over CDC | `@subscription`, from library-made writes or, with `changeFeed: true`, every committed write |
|
|
1293
|
+
| Interfaces and unions with `UNION` | Per-member subqueries merged by sort, each using its own indexes |
|
|
1294
|
+
| Federation | Not supported |
|
|
1048
1295
|
|
|
1049
1296
|
## LoraDB behaviours this works around
|
|
1050
1297
|
|
|
@@ -1062,7 +1309,7 @@ before a following `SET`, list comparison (`[a, b] > $list`) and
|
|
|
1062
1309
|
| An aggregate nested in a call (`head(collect(x))`, `collect(x)[0..2]`) is not aggregated | `WITH collect(x) AS c RETURN head(c)`; `reduce` for per-parent aggregates |
|
|
1063
1310
|
| `max`, `sum` and `avg` over durations are wrong | Duration aggregates are folded with `reduce` |
|
|
1064
1311
|
| Integer division returns a float; negative list slices (`l[..-1]`) return `[]` | `toInteger(a / b)` for Int fields; `l[..size(l) - n]` |
|
|
1065
|
-
| `COUNT { … RETURN DISTINCT x }
|
|
1312
|
+
| `COUNT { … RETURN DISTINCT x }` and `UNION` inside `CALL` do not parse | `reduce` for distinct counts; per-member subqueries |
|
|
1066
1313
|
|
|
1067
1314
|
## Development
|
|
1068
1315
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { GraphQLSchema } from 'graphql';
|
|
2
|
+
import { AuthOperation, GraphModel, ModelWarning } from '../model/types.js';
|
|
3
|
+
export type AccessVerdict =
|
|
4
|
+
/** Every row, for this caller. */
|
|
5
|
+
"allowed"
|
|
6
|
+
/** Only rows a filter rule admits; others are invisible. */
|
|
7
|
+
| "filtered"
|
|
8
|
+
/** Checked per row; a failing row is FORBIDDEN. */
|
|
9
|
+
| "validated"
|
|
10
|
+
/** Rows failing a mask read a substitute value. */
|
|
11
|
+
| "masked"
|
|
12
|
+
/** Never, for this caller. */
|
|
13
|
+
| "denied"
|
|
14
|
+
/** Needs a token this caller does not have. */
|
|
15
|
+
| "unauthenticated";
|
|
16
|
+
export interface AccessEntry {
|
|
17
|
+
type: string;
|
|
18
|
+
/** The field, for field-level and relationship rules. */
|
|
19
|
+
field?: string;
|
|
20
|
+
operation: AuthOperation;
|
|
21
|
+
principal: string;
|
|
22
|
+
verdict: AccessVerdict;
|
|
23
|
+
/** What decides it: `filter[0]`, `validate[1]`, `@authentication`, `bypass`, … */
|
|
24
|
+
by: string[];
|
|
25
|
+
}
|
|
26
|
+
/** The access matrix of `model`, in a stable order. */
|
|
27
|
+
export declare function accessMatrix(model: GraphModel, schema: GraphQLSchema): AccessEntry[];
|
|
28
|
+
/**
|
|
29
|
+
* Authorization lints for `check`: valid rules that are probably not what
|
|
30
|
+
* was meant.
|
|
31
|
+
*/
|
|
32
|
+
export declare function accessLints(model: GraphModel, schema: GraphQLSchema): ModelWarning[];
|
package/dist/analyze/lint.d.ts
CHANGED
|
@@ -4,3 +4,12 @@ export interface LintOptions {
|
|
|
4
4
|
statistics?: boolean;
|
|
5
5
|
}
|
|
6
6
|
export declare function lintModel(model: GraphModel, options?: LintOptions): ModelWarning[];
|
|
7
|
+
/**
|
|
8
|
+
* `@mutation` types with a generated write no rule guards: any caller
|
|
9
|
+
* that reaches the API can make it. An error in `check()`, unless the type
|
|
10
|
+
* declares the operation open with `@authorization(public: [...])`. A
|
|
11
|
+
* write is guarded by `@authentication` for it, a filter or validate rule
|
|
12
|
+
* for it (a `@authorizationDefaults(mutations:)` default included), or a
|
|
13
|
+
* schema bypass alone does not count: it only lets some callers skip rules.
|
|
14
|
+
*/
|
|
15
|
+
export declare function unguardedMutations(model: GraphModel): ModelWarning[];
|