Skip to content

Commit 2a20dc3

Browse files
committed
fix: stricter merge rules for @requiresScopes and @Policy
Current merge policies for `@authenticated`, `@requiresScopes` and `@policy` were inconsistent. If single subgraph declared a field with one of the directives then it would restrict access to this supergraph field regardless which subgraph would resolve this field (results in `AND` rule for any applied auth directive, i.e. `@authenticated` AND `@policy` is required to access this field). If the same auth directive (`@requiresScopes`/`@policy`) were applied across the subgraphs then the resulting supergraph field could be resolved by fullfilling either one of the subgraph requirements (resulting in `OR` rule, i.e. either `@policy` 1 or `@policy` 2 has to be true to access the field). While arguably this allowed for easier schema evolution, it did result in weakening the security requirements. Since `@policy` and `@requiresScopes` values are represent boolean conditions in Disjunctive Normal Form, we can merge them conjunctively to get the final auth requirements, i.e. ```graphql type T @authenticated { # requires scopes (A1 AND A2) OR A3 secret: String @requiresScopes(scopes: [["A1", "A2"], ["A3"]]) } type T { # requires scopes B1 OR B2 secret: String @requiresScopes(scopes: [["B1"], ["B2"]] } type T @authenticated { secret: String @requiresScopes( scopes: [ ["A1", "A2", "B1"], ["A1", "A2", "B2"], ["A3", "B1"], ["A3", "B2"] ]) } ``` This algorithm also deduplicates redundant requirements, e.g. ```graphql type T { # requires A1 AND A2 scopes to access secret: String @requiresScopes(scopes: [["A1", "A2"]]) } type T { # requires only A1 scope to access secret: String @requiresScopes(scopes: [["A1"]]) } type T { # requires only A1 scope to access as A2 is redundant secret: String @requiresScopes(scopes: [["A1"]]) } ```
1 parent 7730c03 commit 2a20dc3

File tree

6 files changed

+220
-14
lines changed

6 files changed

+220
-14
lines changed

.changeset/tasty-snails-invent.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@apollo/composition": patch
3+
"@apollo/federation-internals": patch
4+
---
5+
6+
Stricter merge rules for @requiresScopes and @policy
7+
8+
Current merge policies for `@authenticated`, `@requiresScopes` and `@policy` were inconsistent.
9+
10+
If a shared field uses the same authorization directives across subgraphs, composition merges them using `OR` logic. However, if a shared field uses different authorization directives across subgraphs composition merges them using `AND` logic. This simplified schema evolution, but weakened security requirements. Therefore, the behavior has been changed to always apply `AND` logic to authorization directives applied to the same field across subgraphs.
11+
12+
Since `@policy` and `@requiresScopes` values represent boolean conditions in Disjunctive Normal Form, we can merge them conjunctively to get the final auth requirements. For example:
13+
14+
```graphql
15+
# subgraph A
16+
type T @authenticated {
17+
# requires scopes (A1 AND A2) OR A3
18+
secret: String @requiresScopes(scopes: [["A1", "A2"], ["A3"]])
19+
}
20+
21+
# subgraph B
22+
type T {
23+
# requires scopes B1 OR B2
24+
secret: String @requiresScopes(scopes: [["B1"], ["B2"]]
25+
}
26+
27+
# composed supergraph
28+
type T @authenticated {
29+
secret: String @requiresScopes(
30+
scopes: [
31+
["A1", "A2", "B1"],
32+
["A1", "A2", "B2"],
33+
["A3", "B1"],
34+
["A3", "B2"]
35+
])
36+
}
37+
```
38+
39+
This algorithm also deduplicates redundant requirements, e.g.
40+
41+
```graphql
42+
# subgraph A
43+
type T {
44+
# requires A1 AND A2 scopes to access
45+
secret: String @requiresScopes(scopes: [["A1", "A2"]])
46+
}
47+
48+
# subgraph B
49+
type T {
50+
# requires only A1 scope to access
51+
secret: String @requiresScopes(scopes: [["A1"]])
52+
}
53+
54+
# composed supergraph
55+
type T {
56+
# requires only A1 scope to access as A2 is redundant
57+
secret: String @requiresScopes(scopes: [["A1"]])
58+
}
59+
```

composition-js/src/__tests__/compose.directiveArgumentMergeStrategies.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,22 @@ describe('composition of directive with non-trivial argument strategies', () =>
159159
resultValues: {
160160
t: ['foo', 'bar'], k: ['v1', 'v2'], b: ['x'],
161161
},
162+
},
163+
{
164+
name: 'dnf_conjunction',
165+
// [[String!]!]!
166+
type: (schema: Schema) => new NonNullType(new ListType(
167+
new NonNullType(new ListType(
168+
new NonNullType(schema.stringType())))
169+
)),
170+
compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.DNF_CONJUNCTION,
171+
argValues: {
172+
s1: { t: [['foo'], ['bar']], k: [['v1']] },
173+
s2: { t: [['foo'], ['bar'], ['baz']], k: [['v2', 'v3']], b: [['x']] },
174+
},
175+
resultValues: {
176+
t: [['bar'], ['foo']], k: [['v1', 'v2', 'v3']], b: [['x']],
177+
},
162178
}])('works for $name', ({ name, type, compositionStrategy, argValues, resultValues }) => {
163179
createTestFeature({
164180
url: 'https://specs.apollo.dev',

composition-js/src/__tests__/compose.test.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4421,14 +4421,14 @@ describe('composition', () => {
44214421
expect(result2.schema.type('A')?.hasAppliedDirective(directiveName.slice(1))).toBeTruthy();
44224422
});
44234423

4424-
it.each(testsToRun)('merges ${directiveName} lists (simple union)', ({ directiveName, argName }) => {
4424+
it.each(testsToRun)('merges $directiveName lists (outer sum)', ({ directiveName, argName }) => {
44254425
const a1 = {
44264426
typeDefs: gql`
44274427
type Query {
44284428
a: A
44294429
}
44304430
4431-
type A ${directiveName}(${argName}: ["a"]) @key(fields: "id") {
4431+
type A ${directiveName}(${argName}: [["a1", "a2"], ["a3"]]) @key(fields: "id") {
44324432
id: String!
44334433
a1: String
44344434
}
@@ -4437,7 +4437,7 @@ describe('composition', () => {
44374437
};
44384438
const a2 = {
44394439
typeDefs: gql`
4440-
type A ${directiveName}(${argName}: ["b"]) @key(fields: "id") {
4440+
type A ${directiveName}(${argName}: [["b1"], ["b2"]]) @key(fields: "id") {
44414441
id: String!
44424442
a2: String
44434443
}
@@ -4450,18 +4450,24 @@ describe('composition', () => {
44504450
expect(
44514451
result.schema.type('A')
44524452
?.appliedDirectivesOf(directiveName.slice(1))
4453-
?.[0]?.arguments()?.[argName]).toStrictEqual(['a', 'b']
4453+
?.[0]?.arguments()?.[argName]).toStrictEqual(
4454+
[
4455+
['a3', 'b1'],
4456+
['a3', 'b2'],
4457+
['a1', 'a2', 'b1'],
4458+
['a1', 'a2', 'b2']
4459+
]
44544460
);
44554461
});
44564462

4457-
it.each(testsToRun)('merges ${directiveName} lists (deduplicates intersecting scopes)', ({ directiveName, argName }) => {
4463+
it.each(testsToRun)('merges $directiveName lists (deduplicates redundant scopes)', ({ directiveName, argName }) => {
44584464
const a1 = {
44594465
typeDefs: gql`
44604466
type Query {
44614467
a: A
44624468
}
44634469
4464-
type A ${directiveName}(${argName}: ["a", "b"]) @key(fields: "id") {
4470+
type A ${directiveName}(${argName}: [["a"], ["c"]]) @key(fields: "id") {
44654471
id: String!
44664472
a1: String
44674473
}
@@ -4470,28 +4476,41 @@ describe('composition', () => {
44704476
};
44714477
const a2 = {
44724478
typeDefs: gql`
4473-
type A ${directiveName}(${argName}: ["b", "c"]) @key(fields: "id") {
4479+
type A ${directiveName}(${argName}: [["a"], ["b"], ["c"]]) @key(fields: "id") {
44744480
id: String!
44754481
a2: String
44764482
}
44774483
`,
44784484
name: 'a2',
44794485
};
4486+
const a3 = {
4487+
typeDefs: gql`
4488+
type A ${directiveName}(${argName}: [["a"], ["b", "c"]]) @key(fields: "id") {
4489+
id: String!
4490+
a3: String
4491+
}
4492+
`,
4493+
name: 'a3',
4494+
};
44804495

4481-
const result = composeAsFed2Subgraphs([a1, a2]);
4496+
const result = composeAsFed2Subgraphs([a1, a2, a3]);
44824497
assertCompositionSuccess(result);
44834498
expect(
44844499
result.schema.type('A')
44854500
?.appliedDirectivesOf(directiveName.slice(1))
4486-
?.[0]?.arguments()?.[argName]).toStrictEqual(['a', 'b', 'c']
4501+
?.[0]?.arguments()?.[argName]).toStrictEqual(
4502+
[
4503+
['a'],
4504+
['b', 'c'],
4505+
]
44874506
);
44884507
});
44894508

44904509
it.each(testsToRun)('${directiveName} has correct definition in the supergraph', ({ directiveName, argName, argType, identity }) => {
44914510
const a = {
44924511
typeDefs: gql`
44934512
type Query {
4494-
x: Int ${directiveName}(${argName}: ["a", "b"])
4513+
x: Int ${directiveName}(${argName}: [["a"], ["b"]])
44954514
}
44964515
`,
44974516
name: 'a',

internals-js/src/argumentCompositionStrategies.ts

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { InputType, NonNullType, Schema, isListType, isNonNullType } from "./definitions"
1+
import {InputType, NonNullType, Schema, isListType, isNonNullType} from "./definitions"
22
import { sameType } from "./types";
33
import { valueEquals } from "./values";
44

@@ -19,6 +19,14 @@ function supportFixedTypes(types: (schema: Schema) => InputType[]): TypeSupportV
1919
};
2020
}
2121

22+
function supportAnyNonNullNestedArray(): TypeSupportValidator {
23+
return (_, type) =>
24+
isNonNullType(type) && isListType(type.ofType)
25+
&& isNonNullType(type.ofType.ofType) && isListType(type.ofType.ofType.ofType)
26+
? { valid: true }
27+
: { valid: false, supportedMsg: 'non nullable nested list types of any type' }
28+
}
29+
2230
function supportAnyNonNullArray(): TypeSupportValidator {
2331
return (_, type) => isNonNullType(type) && isListType(type.ofType)
2432
? { valid: true }
@@ -54,6 +62,104 @@ function unionValues(values: any[]): any {
5462
}, []);
5563
}
5664

65+
/**
66+
* Performs conjunction of 2d arrays that represent conditions in Disjunctive Normal Form.
67+
*
68+
* * Each inner array is interpreted as the conjunction of the conditions in the array.
69+
* * The top-level array is interpreted as the disjunction of the inner arrays
70+
*
71+
* Algorithm
72+
* * filter out duplicate entries to limit the amount of necessary computations
73+
* * calculate cartesian product of the arrays to find all possible combinations
74+
* * simplify combinations by dropping duplicate conditions (i.e. p ^ p = p, p ^ q = q ^ p)
75+
* * eliminate entries that are subsumed by others (i.e. (p ^ q) subsumes (p ^ q ^ r))
76+
*/
77+
function dnfConjunction<T>(values: T[][][]): T[][] {
78+
// should never be the case
79+
if (values.length == 0) {
80+
return [];
81+
}
82+
83+
// we first filter out duplicate values from candidates
84+
// this avoids exponential computation of exactly the same conditions
85+
const filtered = filterNestedArrayDuplicates(values);
86+
87+
// initialize with first entry
88+
let result: T[][] = filtered[0];
89+
// perform cartesian product to find all possible entries
90+
for (let i = 1; i < filtered.length; i++) {
91+
const current = filtered[i];
92+
const accumulator: T[][] = [];
93+
const seen = new Set<string>;
94+
95+
for (const accElement of result) {
96+
for (const currentElement of current) {
97+
// filter out elements that are already present in accElement
98+
const filteredElement = currentElement.filter((e) => !accElement.includes(e));
99+
const candidate = [...accElement, ...filteredElement].sort();
100+
const key = JSON.stringify(candidate);
101+
// only add entries which has not been seen yet
102+
if (!seen.has(key)) {
103+
seen.add(key);
104+
accumulator.push(candidate);
105+
}
106+
}
107+
}
108+
// Now we need to deduplicate the results. Given that
109+
// - outer array implies OR requirements
110+
// - inner array implies AND requirements
111+
// We can filter out any inner arrays that fully contain other inner arrays, i.e.
112+
// A OR B OR (A AND B) OR (A AND B AND C) => A OR B
113+
result = deduplicateSubsumedValues(accumulator);
114+
}
115+
return result;
116+
}
117+
118+
function filterNestedArrayDuplicates<T>(values: T[][][]): T[][][] {
119+
const filtered: T[][][] = [];
120+
const seen = new Set<string>;
121+
values.forEach((value) => {
122+
value.sort();
123+
const key = JSON.stringify(value);
124+
if (!seen.has(key)) {
125+
seen.add(key);
126+
filtered.push(value);
127+
}
128+
});
129+
return filtered;
130+
}
131+
132+
function deduplicateSubsumedValues<T>(values: T[][]): T[][] {
133+
const result: T[][] = [];
134+
// we first sort by length as the longer ones might be dropped
135+
values.sort((first, second) => {
136+
if (first.length < second.length) {
137+
return -1;
138+
} else if (first.length > second.length) {
139+
return 1;
140+
} else {
141+
return 0;
142+
}
143+
});
144+
145+
for (const candidate of values) {
146+
const entry = new Set(candidate);
147+
let redundant = false;
148+
for (const r of result) {
149+
if (r.every(e => entry.has(e))) {
150+
// if `r` is a subset of a `candidate` then it means `candidate` is redundant
151+
redundant = true;
152+
break;
153+
}
154+
}
155+
156+
if (!redundant) {
157+
result.push(candidate);
158+
}
159+
}
160+
return result;
161+
}
162+
57163
export const ARGUMENT_COMPOSITION_STRATEGIES = {
58164
MAX: {
59165
name: 'MAX',
@@ -95,7 +201,8 @@ export const ARGUMENT_COMPOSITION_STRATEGIES = {
95201
schema.booleanType(),
96202
new NonNullType(schema.booleanType())
97203
]),
98-
mergeValues: mergeNullableValues(
204+
mergeValues:
205+
mergeNullableValues(
99206
(values: boolean[]) => values.every((v) => v)
100207
),
101208
},
@@ -113,5 +220,10 @@ export const ARGUMENT_COMPOSITION_STRATEGIES = {
113220
name: 'NULLABLE_UNION',
114221
isTypeSupported: supportAnyArray(),
115222
mergeValues: mergeNullableValues(unionValues),
223+
},
224+
DNF_CONJUNCTION: {
225+
name: 'DNF_CONJUNCTION',
226+
isTypeSupported: supportAnyNonNullNestedArray(),
227+
mergeValues: dnfConjunction
116228
}
117229
}

internals-js/src/specs/policySpec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class PolicySpecDefinition extends FeatureDefinition {
4242
assert(PolicyType, () => `Expected "${policyName}" to be defined`);
4343
return new NonNullType(new ListType(new NonNullType(new ListType(new NonNullType(PolicyType)))));
4444
},
45-
compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.UNION,
45+
compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.DNF_CONJUNCTION,
4646
}],
4747
locations: [
4848
DirectiveLocation.FIELD_DEFINITION,

internals-js/src/specs/requiresScopesSpec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class RequiresScopesSpecDefinition extends FeatureDefinition {
4343
assert(scopeType, () => `Expected "${scopeName}" to be defined`);
4444
return new NonNullType(new ListType(new NonNullType(new ListType(new NonNullType(scopeType)))));
4545
},
46-
compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.UNION,
46+
compositionStrategy: ARGUMENT_COMPOSITION_STRATEGIES.DNF_CONJUNCTION,
4747
}],
4848
locations: [
4949
DirectiveLocation.FIELD_DEFINITION,

0 commit comments

Comments
 (0)