Skip to content

Commit 2c12f78

Browse files
authored
docs(federation): improve clarity of @requiresScopes scope combination docs (#267)
Replace "matrix multiplication" terminology with "Cartesian product" which more accurately describes the scope combination algorithm. Simplify explanations with clear algorithm steps, worked examples, and tables. ENG-9185
1 parent ef91d9e commit 2c12f78

1 file changed

Lines changed: 78 additions & 70 deletions

File tree

docs/federation/directives/requiresscopes.mdx

Lines changed: 78 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ scalar openfed__Scope
3232

3333
The "scopes" argument requires an array (GraphQL List) of nested arrays.
3434

35-
The outermost array represents a set of OR scopes.
35+
The **outer array** represents a set of **OR** scopes—the token must satisfy **at least one** of the inner arrays.
3636

37-
Each nested array sibling represents a set of AND scopes.
37+
Each **inner array** represents a set of **AND** scopes—the token must possess **all** scopes within that array.
38+
39+
In other words, `scopes: [["a", "b"], ["c"]]` means: (`"a"` AND `"b"`) OR (`"c"`).
3840

3941
Each element in the AND scopes array should be an `openfed__Scope` Scalar, which is an instance of permissions as
4042
defined in your authentication token.
@@ -251,34 +253,43 @@ type Query {
251253
}
252254
```
253255

254-
### Combining scopes in the same subgraph (matrix multiplication)
255-
Disparate sets of OR scopes affecting a single field will always combine through a multiplication matrix.
256-
This is to ensure data cannot be accessed without all appropriate permissions.
256+
### Combining scopes in the same subgraph
257+
258+
When scopes are defined in multiple places (for example, on a field and on its return type), each set of scopes is
259+
combined with every set from the other definition.
257260

258261
<Warning>
259262
The maximum total number of scopes that can apply to a single field both directly and indirectly is 16.
260263
</Warning>
261264

262-
If one set of AND scopes are declared on a field definition and another set of AND scopes are defined on the
263-
"type level", the scopes will be multiplied together resulting in both sets of AND scopes being required.
265+
**Algorithm**
266+
267+
1. Take each set of scopes from the first definition.
268+
2. Combine it with each set from the second definition.
269+
3. Merge the scopes in each pair into a single AND set.
270+
271+
The resulting scope sets are the Cartesian product of the two scope lists, with each pair merged into a single AND
272+
scope set.
264273

265-
For instance, consider the following federated graph:
274+
#### Simple example
266275

267276
```graphql
268277
# federated graph
269278
type Query {
270-
scalars: [Scalar!]! @requiresScopes(scopes: [["read:query"]]) # scopes defined in the field level
279+
scalars: [Scalar!]! @requiresScopes(scopes: [["read:query"]]) # field-level scopes
271280
}
272281

273-
scalar Scalar @requiresScopes(scopes: [["read:scalar"]]) # scopes defined on the type level
282+
scalar Scalar @requiresScopes(scopes: [["read:scalar"]]) # type-level scopes
274283
```
275284

276-
Selecting the field `Query.scalars` would require the permissions `"read:query"` AND `"read:scalar"`.
277-
This is effectively the same as a single set of AND scopes defined as `[["read:query", "read:scalar"]]`.
285+
Field scopes: `[["read:query"]]`
286+
Type scopes: `[["read:scalar"]]`
278287

279-
If there are multiple sets of AND scopes (multiple OR scopes), each set of AND scopes on the "field level" are
280-
multiplied against each set of AND scopes on the "type level".
281-
Consider the following federated graph:
288+
Result: `[["read:query", "read:scalar"]]`
289+
290+
Selecting `Query.scalars` requires both `"read:query"` AND `"read:scalar"`.
291+
292+
#### Multiple OR scopes
282293

283294
```graphql
284295
# federated graph
@@ -289,16 +300,20 @@ type Query {
289300
scalar Scalar @requiresScopes(scopes: [["read:scalar"]])
290301
```
291302

292-
Selecting the field `Query.scalars` would require the permissions (`"read:query"` AND `"read:scalar"`) OR
293-
(`"read:scalar"` AND `"read:private"`).
294-
This is effectively the same as a single set of OR scopes defined as
295-
`[["read:query", "read:scalar"], ["read:private", "read:scalar"]]`.
303+
Field scopes: `[["read:query"], ["read:private"]]`
304+
Type scopes: `[["read:scalar"]]`
305+
306+
Result:
307+
```js
308+
[
309+
["read:query", "read:scalar"],
310+
["read:private", "read:scalar"]
311+
]
312+
```
296313

314+
An access token requires (`"read:query"` AND `"read:scalar"`) OR (`"read:private"` AND `"read:scalar"`).
297315

298-
Each set of AND scopes on the "field level" will be added to each set of AND scopes on the "type level".
299-
Typically, the number of resultant scopes will be \<number of AND scopes on field level> *
300-
\<number of AND scopes on type level>.
301-
Consider the following federated graph:
316+
#### Full example
302317

303318
```graphql
304319
# federated graph
@@ -309,23 +324,18 @@ type Query {
309324
scalar Scalar @requiresScopes(scopes: [["read:scalar", "read:custom"], ["read:sensitive"]])
310325
```
311326

312-
In the example above, each set of AND scopes defined at the "field level" would be ANDed together with each set of AND
313-
scopes defined at the "type level".
314-
To break this down:
315-
1. `["read:query", "read:field"]` from the "field level" would be added to `["read:scalar", "read:custom"]` from the
316-
"type level", producing `["read:query", "read:field", "read:scalar", "read:custom"]`.
317-
2. `["read:query", "read:field"]` from the "field level" would be added to `["read:sensitive"]` from the "type level",
318-
producing `["read:query", "read:field", "read:sensitive"]`.
319-
3. `["read:private"]` from the "field level" would be added to `["read:scalar", "read:custom"]` from the
320-
"type level", producing `["read:private", "read:scalar", "read:custom"]`.
321-
4. `["read:private"]` from the "field level" would be added to `["read:sensitive"]` from the "type level", producing
322-
`["read:private", "read:sensitive"]`.
323-
5. `["read:list"]` from the "field level" would be added to `["read:scalar", "read:custom"]` from the
324-
"type level", producing `["read:list", "read:scalar", "read:custom"]`.
325-
6. `["read:list"]` from the "field level" would be added to `["read:sensitive"]` from the "type level", producing
326-
`["read:list", "read:sensitive"]`.
327-
328-
This is effectively the same as a single set of scopes defined as:
327+
Field scopes (3 sets) × Type scopes (2 sets) = 6 resulting sets:
328+
329+
| Field set | Type set | Merged result |
330+
| --- | --- | --- |
331+
| `["read:query", "read:field"]` | `["read:scalar", "read:custom"]` | `["read:query", "read:field", "read:scalar", "read:custom"]` |
332+
| `["read:query", "read:field"]` | `["read:sensitive"]` | `["read:query", "read:field", "read:sensitive"]` |
333+
| `["read:private"]` | `["read:scalar", "read:custom"]` | `["read:private", "read:scalar", "read:custom"]` |
334+
| `["read:private"]` | `["read:sensitive"]` | `["read:private", "read:sensitive"]` |
335+
| `["read:list"]` | `["read:scalar", "read:custom"]` | `["read:list", "read:scalar", "read:custom"]` |
336+
| `["read:list"]` | `["read:sensitive"]` | `["read:list", "read:sensitive"]` |
337+
338+
This is effectively the same as:
329339
```js
330340
[
331341
["read:query", "read:field", "read:scalar", "read:custom"],
@@ -337,8 +347,10 @@ This is effectively the same as a single set of scopes defined as:
337347
]
338348
```
339349

340-
### Combining scopes across subgraphs (matrix multiplication)
341-
If multiple instances of a field (shared field) or type define scopes, those scopes will also be multiplied together.
350+
### Combining scopes across subgraphs
351+
352+
When multiple instances of a shared field or type define scopes across different subgraphs, the same combination
353+
rule applies—each set of scopes from one subgraph is combined with every set from the other.
342354

343355
Consider the following subgraphs:
344356
```graphql
@@ -348,7 +360,7 @@ type Query @shareable {
348360
objects: [Object!]!
349361
}
350362

351-
type Object @shareable @requiresScopes(scopes: [["read:object"]]]) {
363+
type Object @shareable @requiresScopes(scopes: [["read:object"]]) {
352364
id: ID!
353365
}
354366
```
@@ -364,8 +376,7 @@ type Object @shareable @requiresScopes(scopes: [["read:type"], ["read:private"]]
364376
}
365377
```
366378

367-
The scopes defined by each instance of the field will be multiplied together, and the resulting product will be shown
368-
in the federated graph:
379+
The scopes from each subgraph are combined, and the result is shown in the federated graph:
369380
```graphql
370381
type Query {
371382
ids: [ID!]! @requiresScopes(scopes: [
@@ -385,11 +396,14 @@ type Object @requiresScopes(scopes: [
385396
}
386397
```
387398

388-
Note that the federated graph will show the combined result of scopes for a specific field or type.
399+
Note that the federated graph shows the combined result of scopes for a specific field or type.
389400
The _effective_ scopes, _i.e._, the "type level" scopes affecting a field, will not be reflected in the federated graph.
390401

391-
### Combining scopes (superset scope reduction)
392-
In the event that combining scopes would produce superfluous scopes, they will be removed.
402+
### Combining scopes (superset reduction)
403+
404+
After scopes are combined, any scope set that is a superset of another set is removed. A superset is
405+
redundant because the smaller set already grants access with fewer permissions.
406+
393407
Consider the following subgraphs:
394408

395409
```graphql
@@ -406,29 +420,23 @@ type Query @shareable {
406420
}
407421
```
408422

409-
One might assume the scopes would merge thus:
410-
```graphql
411-
# federated graph
412-
type Query {
413-
ids: [ID!]! @requiresScopes(scopes: [
414-
["read:id"],
415-
["read:id", "read:field"],
416-
["read:field", "read:id"],
417-
["read:field"],
418-
["read:private", "read:id"],
419-
["read:private", "read:field"]
420-
])
421-
}
422-
```
423+
Combining the scopes produces 6 sets:
424+
425+
| subgraph-a set | subgraph-b set | Merged result |
426+
| --- | --- | --- |
427+
| `["read:id"]` | `["read:id"]` | `["read:id"]` |
428+
| `["read:id"]` | `["read:field"]` | `["read:id", "read:field"]` |
429+
| `["read:field"]` | `["read:id"]` | `["read:field", "read:id"]` |
430+
| `["read:field"]` | `["read:field"]` | `["read:field"]` |
431+
| `["read:private"]` | `["read:id"]` | `["read:private", "read:id"]` |
432+
| `["read:private"]` | `["read:field"]` | `["read:private", "read:field"]` |
433+
434+
After reduction:
435+
- `["read:id", "read:field"]` and `["read:field", "read:id"]` are duplicates and supersets of both `["read:id"]` and `["read:field"]` - removed.
436+
- `["read:private", "read:id"]` is a superset of `["read:id"]` - removed.
437+
- `["read:private", "read:field"]` is a superset of `["read:field"]` - removed.
423438

424-
However, upon closer inspection, it becomes apparent that many of these scopes are either repeated or supersets of
425-
other scopes.
426-
Consequently, the merge result remove any scopes that are supersets of other scopes.
427-
For example, the scope `"read:private"` now only exists with either `"read:id"` or `"read:field"`.
428-
This makes `"read:private"` redundant because if one has either the `"read:id"` or `"read:field"` permissions, this is
429-
sufficient to access the field.
430-
This the same case for the combined scopes of `"read:id"` and `"read:field"`; just one is sufficient.
431-
And so, the resulting federated graph is actually shown below (note that `"read:private"` is removed):
439+
The resulting federated graph:
432440
```graphql
433441
# federated graph
434442
type Query {

0 commit comments

Comments
 (0)