Skip to content

Commit

Permalink
Introduce @authenticated directive in composition (#2644)
Browse files Browse the repository at this point in the history
With this change, users can now compose `@authenticated` directive
applications from their subgraphs into a supergraph. This addition will
support a future version of Apollo Router that enables authenticated
access to specific types and fields via directive applications.

Since the implementation of `@authenticated` is strictly a composition
and execution concern, there's no change to the query planner with
this work. The execution work is well under way in Apollo Router (and
won't be built at all for Gateway). So as far as this repo is concerned,
only composition is concerned with `@authenticated`.
  • Loading branch information
trevor-scheer authored Jul 12, 2023
1 parent aac2893 commit 9396c0d
Show file tree
Hide file tree
Showing 9 changed files with 368 additions and 9 deletions.
28 changes: 28 additions & 0 deletions .changeset/gold-schools-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@apollo/composition": minor
"@apollo/federation-internals": minor
"@apollo/subgraph": minor
"@apollo/gateway": minor
---

Introduce the new `@authenticated` directive for composition

> Note that this directive will only be _fully_ supported by the Apollo Router as a GraphOS Enterprise feature at runtime. Also note that _composition_ of valid `@authenticated` directive applications will succeed, but the resulting supergraph will not be _executable_ by the Gateway or an Apollo Router which doesn't have the GraphOS Enterprise entitlement.
Users may now compose `@authenticated` applications from their subgraphs into a supergraph. This addition will support a future version of Apollo Router that enables authenticated access to specific types and fields via directive applications.

The directive is defined as follows:

```graphql
directive @authenticated on
| FIELD_DEFINITION
| OBJECT
| INTERFACE
| SCALAR
| ENUM
```

In order to compose your `@authenticated` usages, you must update your subgraph's federation spec version to v2.5 and add the `@authenticated` import to your existing imports like so:
```graphql
@link(url: "https://specs.apollo.dev/federation/v2.5", import: [..., "@authenticated"])
```
6 changes: 3 additions & 3 deletions composition-js/src/__tests__/compose.composeDirective.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ describe('composing custom core directives', () => {
});

it.each([
'@tag', '@inaccessible',
'@tag', '@inaccessible', '@authenticated',
])('federation directives that result in a hint', (directive) => {
const subgraphA = generateSubgraph({
name: 'subgraphA',
Expand All @@ -282,13 +282,13 @@ describe('composing custom core directives', () => {
});

it.each([
'@tag', '@inaccessible',
'@tag', '@inaccessible', '@authenticated',
])('federation directives (with rename) that result in a hint', (directive) => {
const subgraphA = {
name: 'subgraphA',
typeDefs: gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.1", import: [{ name: "@key" }, { name: "@composeDirective" } , { name: "${directive}", as: "@apolloDirective" }])
@link(url: "https://specs.apollo.dev/federation/v2.5", import: [{ name: "@key" }, { name: "@composeDirective" } , { name: "${directive}", as: "@apolloDirective" }])
@link(url: "https://specs.apollo.dev/link/v1.0")
@composeDirective(name: "@apolloDirective")
Expand Down
220 changes: 220 additions & 0 deletions composition-js/src/__tests__/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
asFed2SubgraphDocument,
assert,
buildSubgraph,
DEFAULT_SUPPORTED_SUPERGRAPH_FEATURES,
defaultPrintOptions,
FEDERATION2_LINK_WITH_FULL_IMPORTS,
inaccessibleIdentity,
Expand Down Expand Up @@ -4008,4 +4009,223 @@ describe('composition', () => {
assertCompositionSuccess(result);
});
});

describe('@authenticated', () => {
// We need to override the default supported features to include the
// @authenticated feature, since it's not part of the default supported
// features.
const supportedFeatures = new Set([
...DEFAULT_SUPPORTED_SUPERGRAPH_FEATURES,
'https://specs.apollo.dev/authenticated/v0.1',
]);

it('comprehensive locations', () => {
const onObject = {
typeDefs: gql`
type Query {
object: AuthenticatedObject!
}
type AuthenticatedObject @authenticated {
field: Int!
}
`,
name: 'on-object',
};

const onInterface = {
typeDefs: gql`
type Query {
interface: AuthenticatedInterface!
}
interface AuthenticatedInterface @authenticated {
field: Int!
}
`,
name: 'on-interface',
};

const onInterfaceObject = {
typeDefs: gql`
type AuthenticatedInterfaceObject
@interfaceObject
@key(fields: "id")
@authenticated
{
id: String!
}
`,
name: 'on-interface-object',
}

const onScalar = {
typeDefs: gql`
scalar AuthenticatedScalar @authenticated
# This needs to exist in at least one other subgraph from where it's defined
# as an @interfaceObject (so arbitrarily adding it here). We don't actually
# apply @authenticated to this one since we want to see it propagate even
# when it's not applied in all locations.
interface AuthenticatedInterfaceObject @key(fields: "id") {
id: String!
}
`,
name: 'on-scalar',
};

const onEnum = {
typeDefs: gql`
enum AuthenticatedEnum @authenticated {
A
B
}
`,
name: 'on-enum',
};

const onRootField = {
typeDefs: gql`
type Query {
authenticatedRootField: Int! @authenticated
}
`,
name: 'on-root-field',
};

const onObjectField = {
typeDefs: gql`
type Query {
objectWithField: ObjectWithAuthenticatedField!
}
type ObjectWithAuthenticatedField {
field: Int! @authenticated
}
`,
name: 'on-object-field',
};

const onEntityField = {
typeDefs: gql`
type Query {
entityWithField: EntityWithAuthenticatedField!
}
type EntityWithAuthenticatedField @key(fields: "id") {
id: ID!
field: Int! @authenticated
}
`,
name: 'on-entity-field',
};

const result = composeAsFed2Subgraphs([
onObject,
onInterface,
onInterfaceObject,
onScalar,
onEnum,
onRootField,
onObjectField,
onEntityField,
], { supportedFeatures });
assertCompositionSuccess(result);

const authenticatedElements = [
"AuthenticatedObject",
"AuthenticatedInterface",
"AuthenticatedInterfaceObject",
"AuthenticatedScalar",
"AuthenticatedEnum",
"Query.authenticatedRootField",
"ObjectWithAuthenticatedField.field",
"EntityWithAuthenticatedField.field",
];

for (const element of authenticatedElements) {
expect(
result.schema
.elementByCoordinate(element)
?.hasAppliedDirective("authenticated")
).toBeTruthy();
}
});

it('applies @authenticated on types as long as it is used once', () => {
const a1 = {
typeDefs: gql`
type Query {
a: A
}
type A @key(fields: "id") @authenticated {
id: String!
a1: String
}
`,
name: 'a1',
};
const a2 = {
typeDefs: gql`
type A @key(fields: "id") {
id: String!
a2: String
}
`,
name: 'a2',
};

// checking composition in either order (not sure if this is necessary but
// it's not hurting anything)
const result1 = composeAsFed2Subgraphs([a1, a2], { supportedFeatures });
const result2 = composeAsFed2Subgraphs([a2, a1], { supportedFeatures });
assertCompositionSuccess(result1);
assertCompositionSuccess(result2);

expect(result1.schema.type('A')?.hasAppliedDirective('authenticated')).toBeTruthy();
expect(result2.schema.type('A')?.hasAppliedDirective('authenticated')).toBeTruthy();
});

it('validation error on incompatible directive definition', () => {
const invalidDefinition = {
typeDefs: gql`
directive @authenticated on ENUM_VALUE
type Query {
a: Int
}
enum E {
A @authenticated
}
`,
name: 'invalidDefinition',
};
const result = composeAsFed2Subgraphs([invalidDefinition], { supportedFeatures });
expect(errors(result)[0]).toEqual([
"DIRECTIVE_DEFINITION_INVALID",
"[invalidDefinition] Invalid definition for directive \"@authenticated\": \"@authenticated\" should have locations FIELD_DEFINITION, OBJECT, INTERFACE, SCALAR, ENUM, but found (non-subset) ENUM_VALUE",
]);
});

it('validation error on invalid application', () => {
const invalidApplication = {
typeDefs: gql`
type Query {
a: Int
}
enum E {
A @authenticated
}
`,
name: 'invalidApplication',
};
const result = composeAsFed2Subgraphs([invalidApplication], { supportedFeatures });
expect(errors(result)[0]).toEqual([
"INVALID_GRAPHQL",
"[invalidApplication] Directive \"@authenticated\" may not be used on ENUM_VALUE.",
]);
});
});
});
7 changes: 3 additions & 4 deletions composition-js/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,8 @@ export interface CompositionSuccess {

export interface CompositionOptions {
sdlPrintOptions?: PrintOptions;


allowedFieldTypeMergingSubtypingRules?: SubtypingRule[]
allowedFieldTypeMergingSubtypingRules?: SubtypingRule[];
supportedFeatures?: Set<string>;
}

function validateCompositionOptions(options: CompositionOptions) {
Expand Down Expand Up @@ -66,7 +65,7 @@ export function compose(subgraphs: Subgraphs, options: CompositionOptions = {}):
return { errors: mergeResult.errors };
}

const supergraph = new Supergraph(mergeResult.supergraph);
const supergraph = new Supergraph(mergeResult.supergraph, options.supportedFeatures);
const supergraphQueryGraph = buildSupergraphAPIQueryGraph(supergraph);
const federatedQueryGraph = buildFederatedQueryGraph(supergraph, false);
const { errors, hints } = validateGraphComposition(supergraph.schema, supergraphQueryGraph, federatedQueryGraph);
Expand Down
2 changes: 2 additions & 0 deletions composition-js/src/composeDirectiveManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const DISALLOWED_IDENTITIES = [
'https://specs.apollo.dev/tag',
'https://specs.apollo.dev/inaccessible',
'https://specs.apollo.dev/federation',
'https://specs.apollo.dev/authenticated',
];

export class ComposeDirectiveManager {
Expand Down Expand Up @@ -170,6 +171,7 @@ export class ComposeDirectiveManager {
const directivesComposedByDefault = [
sg.metadata().tagDirective(),
sg.metadata().inaccessibleDirective(),
sg.metadata().authenticatedDirective(),
].map(d => d.name);
if (directivesComposedByDefault.includes(directive.name)) {
this.pushHint(new CompositionHint(
Expand Down
28 changes: 28 additions & 0 deletions gateway-js/src/__tests__/gateway/endToEnd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { startSubgraphsAndGateway, Services } from './testUtils'
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
import { QueryPlan } from '@apollo/query-planner';
import { createHash } from '@apollo/utils.createhash';
import { ApolloGateway, LocalCompose } from '@apollo/gateway';

function approximateObjectSize<T>(obj: T): number {
return Buffer.byteLength(JSON.stringify(obj), 'utf8');
Expand Down Expand Up @@ -483,4 +484,31 @@ describe('end-to-end features', () => {
}
`);
});

it('explicitly errors on @authenticated import', async () => {
const subgraphA = {
name: 'A',
typeDefs: gql`
extend schema
@link(
url: "https://specs.apollo.dev/federation/v2.5"
import: ["@authenticated"]
)
type Query {
a: Int @authenticated
}
`,
};

const gateway = new ApolloGateway({
supergraphSdl: new LocalCompose({
localServiceList: [subgraphA],
}),
});

await expect(gateway.load()).rejects.toThrowError(
"feature https://specs.apollo.dev/authenticated/v0.1 is for: SECURITY but is unsupported"
);
});
});
Loading

0 comments on commit 9396c0d

Please sign in to comment.