Skip to content

Commit 09e596e

Browse files
committed
fix: allow interface object fields to specify access control
Update composition logic to allow specifying access control directives (`@authenticated`, `@requiresScopes` and `@policy`) on `@interfaceObject` fields. While we disallow access control on interface types and fields, we decided to support it on `@interfaceObject` as it is a useful pattern to define a single resolver (that may need access controls) for common interface fields. Alternative would require our users to explicitly define resolvers for all implementations which defeats the purpose of `@interfaceObject`. This PR refactors in how we propagate access control by providing additional merge sources when merging directives on interfaces, interface fields and object fields.
1 parent b19431e commit 09e596e

File tree

6 files changed

+572
-114
lines changed

6 files changed

+572
-114
lines changed

.changeset/shaggy-adults-help.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@apollo/composition": patch
3+
"@apollo/federation-internals": patch
4+
---
5+
Allow interface object fields to specify access control
6+
7+
Update composition logic to allow specifying access control directives (`@authenticated`, `@requiresScopes` and `@policy`) on `@interfaceObject` fields. While we disallow access control on interface types and fields, we decided to support it on `@interfaceObject` as it is a useful pattern to define a single resolver (that may need access controls) for common interface fields. Alternative would require our users to explicitly define resolvers for all implementations which defeats the purpose of `@interfaceObject`.
8+
9+
This PR refactors in how we propagate access control by providing additional merge sources when merging directives on interfaces, interface fields and object fields.

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

Lines changed: 337 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
assertCompositionSuccess,
44
composeAsFed2Subgraphs,
55
} from "./testHelper";
6-
import {InterfaceType} from "@apollo/federation-internals";
6+
import {InterfaceType, ObjectType} from "@apollo/federation-internals";
77

88
describe('authorization tests', () => {
99
describe("@requires", () => {
@@ -474,6 +474,55 @@ describe('authorization tests', () => {
474474
);
475475
})
476476

477+
it('verifies access control on chain of requires', () => {
478+
const subgraph1 = {
479+
name: 'Subgraph1',
480+
url: 'https://Subgraph1',
481+
typeDefs: gql`
482+
type Query {
483+
t: T
484+
}
485+
486+
type T @key(fields: "id") {
487+
id: ID
488+
extra: String @external
489+
requiresExtra: String @requires(fields: "extra")
490+
}
491+
`
492+
}
493+
494+
const subgraph2 = {
495+
name: 'Subgraph2',
496+
url: 'https://Subgraph2',
497+
typeDefs: gql`
498+
type T @key(fields: "id") {
499+
id: ID
500+
secret: String @external
501+
extra: String @requires(fields: "secret")
502+
}
503+
`
504+
}
505+
506+
const subgraph3 = {
507+
name: 'Subgraph3',
508+
url: 'https://Subgraph3',
509+
typeDefs: gql`
510+
type T @key(fields: "id") {
511+
id: ID
512+
secret: String @authenticated @inaccessible
513+
}
514+
`
515+
}
516+
517+
const result = composeAsFed2Subgraphs([subgraph1, subgraph2, subgraph3]);
518+
expect(result.schema).toBeUndefined();
519+
expect(result.errors?.length).toBe(1);
520+
expect(result.errors?.[0].message).toBe(
521+
'[Subgraph2] Field "T.extra" does not specify necessary @authenticated, @requiresScopes and/or ' +
522+
'@policy auth requirements to access the transitive field "T.secret" data from @requires selection set.'
523+
);
524+
})
525+
477526
it('works with chain of requires', () => {
478527
const subgraph1 = {
479528
name: 'Subgraph1',
@@ -517,6 +566,140 @@ describe('authorization tests', () => {
517566
const result = composeAsFed2Subgraphs([subgraph1, subgraph2, subgraph3]);
518567
assertCompositionSuccess(result);
519568
})
569+
570+
it('works with interface objects', () => {
571+
const subgraph1 = {
572+
name: 'Subgraph1',
573+
url: 'https://Subgraph1',
574+
typeDefs: gql`
575+
type Query {
576+
i: I
577+
}
578+
579+
type I @interfaceObject @key(fields: "id") {
580+
id: ID!
581+
extra: String @external
582+
requiresExtra: String @requires(fields: "extra") @authenticated
583+
}
584+
`
585+
}
586+
587+
const subgraph2 = {
588+
name: 'Subgraph2',
589+
url: 'https://Subgraph2',
590+
typeDefs: gql`
591+
interface I @key(fields: "id") {
592+
id: ID!
593+
extra: String
594+
}
595+
596+
type T @key(fields: "id") {
597+
id: ID
598+
extra: String @authenticated
599+
}
600+
`
601+
}
602+
603+
const result = composeAsFed2Subgraphs([subgraph1, subgraph2]);
604+
assertCompositionSuccess(result);
605+
const interfaceI = result.schema.type("I") as InterfaceType;
606+
expect(interfaceI).toBeDefined();
607+
const requiresExtraField = interfaceI.field('requiresExtra');
608+
expect(requiresExtraField).toBeDefined();
609+
expect(requiresExtraField?.appliedDirectivesOf("authenticated")).toBeDefined();
610+
})
611+
612+
it('works with interface object chains', () => {
613+
const subgraph1 = {
614+
name: 'Subgraph1',
615+
url: 'https://Subgraph1',
616+
typeDefs: gql`
617+
type Query {
618+
i: I
619+
}
620+
621+
type I @interfaceObject @key(fields: "id") {
622+
id: ID!
623+
extra: String @external
624+
requiresExtra: String @requires(fields: "extra") @authenticated
625+
}
626+
`
627+
}
628+
629+
const subgraph2 = {
630+
name: 'Subgraph2',
631+
url: 'https://Subgraph2',
632+
typeDefs: gql`
633+
type I @interfaceObject @key(fields: "id") {
634+
id: ID!
635+
secret: String @external
636+
extra: String @requires(fields: "secret") @authenticated
637+
}
638+
`
639+
}
640+
641+
const subgraph3 = {
642+
name: 'Subgraph3',
643+
url: 'https://Subgraph3',
644+
typeDefs: gql`
645+
interface I @key(fields: "id") {
646+
id: ID!
647+
secret: String
648+
}
649+
650+
type T implements I @key(fields: "id") {
651+
id: ID!
652+
secret: String @authenticated
653+
}
654+
`
655+
}
656+
657+
const result = composeAsFed2Subgraphs([subgraph1, subgraph2, subgraph3]);
658+
assertCompositionSuccess(result);
659+
})
660+
661+
it('verifies requires on interface objects without auth', () => {
662+
const subgraph1 = {
663+
name: 'Subgraph1',
664+
url: 'https://Subgraph1',
665+
typeDefs: gql`
666+
type Query {
667+
i: I
668+
}
669+
670+
type I @interfaceObject @key(fields: "id") {
671+
id: ID!
672+
extra: String @external
673+
requiresExtra: String @requires(fields: "extra")
674+
}
675+
`
676+
}
677+
678+
const subgraph2 = {
679+
name: 'Subgraph2',
680+
url: 'https://Subgraph2',
681+
typeDefs: gql`
682+
interface I @key(fields: "id") {
683+
id: ID!
684+
extra: String
685+
}
686+
687+
type T implements I @key(fields: "id") {
688+
id: ID!
689+
extra: String @authenticated
690+
}
691+
`
692+
}
693+
694+
const result = composeAsFed2Subgraphs([subgraph1, subgraph2]);
695+
console.log(result.supergraphSdl);
696+
expect(result.schema).toBeUndefined();
697+
expect(result.errors?.length).toBe(1);
698+
expect(result.errors?.[0].message).toBe(
699+
'[Subgraph1] Field "I.requiresExtra" does not specify necessary @authenticated, @requiresScopes and/or @policy' +
700+
' auth requirements to access the transitive field "T.extra" data from @requires selection set.'
701+
);
702+
})
520703
});
521704

522705
describe("@context", () => {
@@ -802,7 +985,7 @@ describe('authorization tests', () => {
802985
assertCompositionSuccess(result);
803986
expect(
804987
result.schema.type('I')?.appliedDirectivesOf("authenticated")?.[0]
805-
);
988+
).toBeDefined();
806989
})
807990

808991
it('propagates @requiresScopes from type', () => {
@@ -1139,5 +1322,157 @@ describe('authorization tests', () => {
11391322
]
11401323
);
11411324
})
1325+
1326+
it('works with interface objects', () => {
1327+
const subgraph1 = {
1328+
name: 'Subgraph1',
1329+
url: 'https://Subgraph1',
1330+
typeDefs: gql`
1331+
type Query {
1332+
i: I
1333+
}
1334+
1335+
type I @interfaceObject @key(fields: "id") {
1336+
id: ID!
1337+
secret: String @requiresScopes(scopes: [["S1"]])
1338+
}
1339+
`
1340+
}
1341+
1342+
const subgraph2 = {
1343+
name: 'Subgraph2',
1344+
url: 'https://Subgraph2',
1345+
typeDefs: gql`
1346+
interface I @key(fields: "id") {
1347+
id: ID!
1348+
extra: String
1349+
}
1350+
1351+
type T implements I @key(fields: "id") {
1352+
id: ID!
1353+
extra: String @authenticated
1354+
}
1355+
1356+
type U implements I @key(fields: "id") {
1357+
id: ID!
1358+
extra: String
1359+
}
1360+
`
1361+
}
1362+
1363+
const result = composeAsFed2Subgraphs([subgraph1, subgraph2]);
1364+
assertCompositionSuccess(result);
1365+
})
1366+
1367+
it('works with shareable interface object fields', () => {
1368+
const subgraph1 = {
1369+
name: 'Subgraph1',
1370+
url: 'https://Subgraph1',
1371+
typeDefs: gql`
1372+
type Query {
1373+
i: I
1374+
}
1375+
1376+
type I @interfaceObject @key(fields: "id") {
1377+
id: ID!
1378+
secret: String @requiresScopes(scopes: [["S1"]]) @shareable
1379+
}
1380+
`
1381+
}
1382+
1383+
const subgraph2 = {
1384+
name: 'Subgraph2',
1385+
url: 'https://Subgraph2',
1386+
typeDefs: gql`
1387+
interface I @key(fields: "id") {
1388+
id: ID!
1389+
extra: String
1390+
}
1391+
1392+
type T implements I @key(fields: "id") {
1393+
id: ID!
1394+
extra: String @authenticated
1395+
}
1396+
1397+
type U implements I @key(fields: "id") {
1398+
id: ID!
1399+
extra: String
1400+
}
1401+
`
1402+
}
1403+
1404+
const subgraph3 = {
1405+
name: 'Subgraph3',
1406+
url: 'https://Subgraph3',
1407+
typeDefs: gql`
1408+
type T @key(fields: "id") {
1409+
id: ID!
1410+
secret: String @requiresScopes(scopes: [["S2"]]) @shareable
1411+
}
1412+
`
1413+
}
1414+
1415+
const result = composeAsFed2Subgraphs([subgraph1, subgraph2, subgraph3]);
1416+
assertCompositionSuccess(result);
1417+
// interface I {
1418+
// id: ID!
1419+
// secret: String @requiresScopes(scopes: [["S1", "S2"]])
1420+
// extra: String @authenticated
1421+
// }
1422+
const i = result.schema.type("I");
1423+
expect(i).toBeDefined();
1424+
expect(i).toBeInstanceOf(InterfaceType);
1425+
const secretI = (i as InterfaceType).field("secret");
1426+
expect(secretI?.appliedDirectivesOf("requiresScopes")
1427+
?.[0]?.arguments()?.["scopes"]).toStrictEqual(
1428+
[
1429+
['S1', 'S2'],
1430+
]
1431+
);
1432+
const extraI = (i as InterfaceType).field("extra");
1433+
expect(extraI?.appliedDirectivesOf("authenticated")
1434+
?.[0]
1435+
).toBeDefined();
1436+
1437+
// type T implements I {
1438+
// id: ID!
1439+
// extra: String @authenticated
1440+
// secret: String @requiresScopes(scopes: [["S1", "S2"]])
1441+
// }
1442+
const t = result.schema.type("T");
1443+
expect(t).toBeDefined();
1444+
expect(t).toBeInstanceOf(ObjectType);
1445+
const secretT = (t as ObjectType).field("secret");
1446+
expect(secretT?.appliedDirectivesOf("requiresScopes")
1447+
?.[0]?.arguments()?.["scopes"]).toStrictEqual(
1448+
[
1449+
['S1', 'S2'],
1450+
]
1451+
);
1452+
const extraT = (t as ObjectType).field("extra");
1453+
expect(extraT?.appliedDirectivesOf("authenticated")
1454+
?.[0]
1455+
).toBeDefined();
1456+
1457+
// type U implements I {
1458+
// id: ID!
1459+
// extra: String
1460+
// secret: String @requiresScopes(scopes: [["S1", "S2"]])
1461+
// }
1462+
const u = result.schema.type("U");
1463+
expect(u).toBeDefined();
1464+
expect(u).toBeInstanceOf(ObjectType);
1465+
const secretU = (u as ObjectType).field("secret");
1466+
expect(secretU?.appliedDirectivesOf("requiresScopes")
1467+
?.[0]?.arguments()?.["scopes"]).toStrictEqual(
1468+
[
1469+
['S1', 'S2'],
1470+
]
1471+
);
1472+
const extraU = (u as ObjectType).field("extra");
1473+
expect(extraU?.appliedDirectivesOf("authenticated")
1474+
?.[0]
1475+
).toBeUndefined();
1476+
})
11421477
});
11431478
});

0 commit comments

Comments
 (0)