Skip to content

Commit f6af504

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 99f2da2 commit f6af504

6 files changed

Lines changed: 220 additions & 14 deletions

File tree

.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
@@ -4560,14 +4560,14 @@ describe('composition', () => {
45604560
expect(result2.schema.type('A')?.hasAppliedDirective(directiveName.slice(1))).toBeTruthy();
45614561
});
45624562

4563-
it.each(testsToRun)('merges ${directiveName} lists (simple union)', ({ directiveName, argName }) => {
4563+
it.each(testsToRun)('merges $directiveName lists (outer sum)', ({ directiveName, argName }) => {
45644564
const a1 = {
45654565
typeDefs: gql`
45664566
type Query {
45674567
a: A
45684568
}
45694569
4570-
type A ${directiveName}(${argName}: ["a"]) @key(fields: "id") {
4570+
type A ${directiveName}(${argName}: [["a1", "a2"], ["a3"]]) @key(fields: "id") {
45714571
id: String!
45724572
a1: String
45734573
}
@@ -4576,7 +4576,7 @@ describe('composition', () => {
45764576
};
45774577
const a2 = {
45784578
typeDefs: gql`
4579-
type A ${directiveName}(${argName}: ["b"]) @key(fields: "id") {
4579+
type A ${directiveName}(${argName}: [["b1"], ["b2"]]) @key(fields: "id") {
45804580
id: String!
45814581
a2: String
45824582
}
@@ -4589,18 +4589,24 @@ describe('composition', () => {
45894589
expect(
45904590
result.schema.type('A')
45914591
?.appliedDirectivesOf(directiveName.slice(1))
4592-
?.[0]?.arguments()?.[argName]).toStrictEqual(['a', 'b']
4592+
?.[0]?.arguments()?.[argName]).toStrictEqual(
4593+
[
4594+
['a3', 'b1'],
4595+
['a3', 'b2'],
4596+
['a1', 'a2', 'b1'],
4597+
['a1', 'a2', 'b2']
4598+
]
45934599
);
45944600
});
45954601

4596-
it.each(testsToRun)('merges ${directiveName} lists (deduplicates intersecting scopes)', ({ directiveName, argName }) => {
4602+
it.each(testsToRun)('merges $directiveName lists (deduplicates redundant scopes)', ({ directiveName, argName }) => {
45974603
const a1 = {
45984604
typeDefs: gql`
45994605
type Query {
46004606
a: A
46014607
}
46024608
4603-
type A ${directiveName}(${argName}: ["a", "b"]) @key(fields: "id") {
4609+
type A ${directiveName}(${argName}: [["a"], ["c"]]) @key(fields: "id") {
46044610
id: String!
46054611
a1: String
46064612
}
@@ -4609,28 +4615,41 @@ describe('composition', () => {
46094615
};
46104616
const a2 = {
46114617
typeDefs: gql`
4612-
type A ${directiveName}(${argName}: ["b", "c"]) @key(fields: "id") {
4618+
type A ${directiveName}(${argName}: [["a"], ["b"], ["c"]]) @key(fields: "id") {
46134619
id: String!
46144620
a2: String
46154621
}
46164622
`,
46174623
name: 'a2',
46184624
};
4625+
const a3 = {
4626+
typeDefs: gql`
4627+
type A ${directiveName}(${argName}: [["a"], ["b", "c"]]) @key(fields: "id") {
4628+
id: String!
4629+
a3: String
4630+
}
4631+
`,
4632+
name: 'a3',
4633+
};
46194634

4620-
const result = composeAsFed2Subgraphs([a1, a2]);
4635+
const result = composeAsFed2Subgraphs([a1, a2, a3]);
46214636
assertCompositionSuccess(result);
46224637
expect(
46234638
result.schema.type('A')
46244639
?.appliedDirectivesOf(directiveName.slice(1))
4625-
?.[0]?.arguments()?.[argName]).toStrictEqual(['a', 'b', 'c']
4640+
?.[0]?.arguments()?.[argName]).toStrictEqual(
4641+
[
4642+
['a'],
4643+
['b', 'c'],
4644+
]
46264645
);
46274646
});
46284647

46294648
it.each(testsToRun)('${directiveName} has correct definition in the supergraph', ({ directiveName, argName, argType, identity }) => {
46304649
const a = {
46314650
typeDefs: gql`
46324651
type Query {
4633-
x: Int ${directiveName}(${argName}: ["a", "b"])
4652+
x: Int ${directiveName}(${argName}: [["a"], ["b"]])
46344653
}
46354654
`,
46364655
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)