Skip to content

Commit b4e462a

Browse files
committed
[backend] Introduce @ff directive to restrict access to endpoints based on FFs
1 parent 274e8df commit b4e462a

6 files changed

Lines changed: 159 additions & 0 deletions

File tree

opencti-platform/opencti-front/src/schema/relay.schema.graphql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ directive @allowUnlicensedLTS on OBJECT | FIELD_DEFINITION
88

99
directive @constraint(minLength: Int, maxLength: Int, startsWith: String, endsWith: String, notContains: String, pattern: String, format: String, min: Int, max: Int, exclusiveMin: Int, exclusiveMax: Int, multipleOf: Int) on INPUT_FIELD_DEFINITION
1010

11+
directive @ff(flags: [String!]!, softFail: Boolean = false) on FIELD_DEFINITION
12+
1113
"""Controls the rate of traffic."""
1214
directive @rateLimit(
1315
"""Number of occurrences allowed over duration."""

opencti-platform/opencti-graphql/config/schema/opencti.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ directive @constraint(
2121
exclusiveMax: Int
2222
multipleOf: Int
2323
) on INPUT_FIELD_DEFINITION
24+
directive @ff(flags: [String!]!, softFail: Boolean = false) on FIELD_DEFINITION
2425

2526
### SCALAR
2627

opencti-platform/opencti-graphql/src/generated/graphql.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40779,6 +40779,13 @@ export type ConstraintDirectiveArgs = {
4077940779

4078040780
export type ConstraintDirectiveResolver<Result, Parent, ContextType = any, Args = ConstraintDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
4078140781

40782+
export type FfDirectiveArgs = {
40783+
flags: Array<Scalars['String']['input']>;
40784+
softFail?: Maybe<Scalars['Boolean']['input']>;
40785+
};
40786+
40787+
export type FfDirectiveResolver<Result, Parent, ContextType = any, Args = FfDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
40788+
4078240789
export type PublicDirectiveArgs = { };
4078340790

4078440791
export type PublicDirectiveResolver<Result, Parent, ContextType = any, Args = PublicDirectiveArgs> = DirectiveResolverFn<Result, Parent, ContextType, Args>;
@@ -52732,5 +52739,6 @@ export type DirectiveResolvers<ContextType = any> = ResolversObject<{
5273252739
allowUnprotectedOTP?: AllowUnprotectedOtpDirectiveResolver<any, any, ContextType>;
5273352740
auth?: AuthDirectiveResolver<any, any, ContextType>;
5273452741
constraint?: ConstraintDirectiveResolver<any, any, ContextType>;
52742+
ff?: FfDirectiveResolver<any, any, ContextType>;
5273552743
public?: PublicDirectiveResolver<any, any, ContextType>;
5273652744
}>;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
2+
import { defaultFieldResolver } from 'graphql';
3+
import type { GraphQLFieldConfig, GraphQLSchema } from 'graphql';
4+
import { ForbiddenAccess } from '../config/errors';
5+
import type { AuthContext } from '../types/user';
6+
import { isFeatureEnabled } from '../config/conf';
7+
8+
/**
9+
* Arguments passed to the feature-flagging directive in GraphQL schema
10+
* @example @ff(flags: ["SOME_FLAG", "SOME_OTHER_FLAG"], softFail: true)
11+
*/
12+
interface FeatureFlagDirectiveArgs {
13+
/**
14+
* Array of feature flags that allow access to the endpoint.
15+
* If at least one flag is set for current user then access
16+
* is granted. (i.e. an `or` operator is used between each one of them)
17+
*/
18+
flags: string[];
19+
/**
20+
* If true indicates that a lack of enabled flag won't result
21+
* in an error but in returning the value `null`.
22+
* Can be useful for instance when querying very early in the app lifecycle
23+
* before feature flags being available in the client.
24+
* Defaults to `false`.
25+
*/
26+
softFail: boolean;
27+
}
28+
29+
const FF_DIRECTIVE = 'ff';
30+
31+
export const makeFeatureFlagDirectiveTransformer = (): (schema: GraphQLSchema) => GraphQLSchema => {
32+
return (schema: GraphQLSchema) => mapSchema(schema, {
33+
[MapperKind.OBJECT_FIELD]: (fieldConfig: GraphQLFieldConfig<any, any>, _fieldName: string) => {
34+
const directive = getDirective(schema, fieldConfig, FF_DIRECTIVE);
35+
const ffDirective = directive?.[0] as FeatureFlagDirectiveArgs | undefined;
36+
37+
if (!ffDirective) {
38+
return fieldConfig;
39+
}
40+
41+
const { flags, softFail } = ffDirective;
42+
if (!flags) {
43+
return fieldConfig;
44+
}
45+
46+
const { resolve = defaultFieldResolver } = fieldConfig;
47+
fieldConfig.resolve = (source: any, args: any, context: AuthContext, info: any) => {
48+
if (!flags.some((flag) => isFeatureEnabled(flag))) {
49+
if (softFail) {
50+
return null;
51+
} else {
52+
throw ForbiddenAccess();
53+
}
54+
}
55+
return resolve(source, args, context, info);
56+
};
57+
return fieldConfig;
58+
},
59+
});
60+
};

opencti-platform/opencti-graphql/src/graphql/schema.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import stixMetaObjectResolvers from '../resolvers/stixMetaObject';
7272
import filterKeysSchemaResolver from '../resolvers/filterKeysSchema';
7373
import basicObjectResolvers from '../resolvers/basicObject';
7474
import { FunctionalError } from '../config/errors';
75+
import { featureFlagDirectiveBuilder, getFeatureFlagDirectiveTransformer, makeFeatureFlagDirectiveTransformer } from './featureFlagDirective';
7576

7677
const schemaTypeDefs = [globalTypeDefs];
7778

@@ -280,6 +281,7 @@ const createSchema = () => {
280281
});
281282
schema = constraintDirectiveDocumentation()(schema);
282283
schema = rateLimitDirectiveTransformer(authDirectiveTransformer(schema));
284+
schema = makeFeatureFlagDirectiveTransformer()(schema);
283285
return schema;
284286
};
285287

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { graphql, parse } from 'graphql';
3+
import { makeExecutableSchema } from '@graphql-tools/schema';
4+
import { makeFeatureFlagDirectiveTransformer } from '../../../src/graphql/featureFlagDirective';
5+
import { isFeatureEnabled } from '../../../src/config/conf';
6+
7+
vi.mock('../../../src/config/conf', () => ({
8+
isFeatureEnabled: vi.fn(),
9+
}));
10+
11+
describe('featureFlagDirective', () => {
12+
afterEach(() => {
13+
vi.resetAllMocks();
14+
});
15+
16+
it('returns a Forbidden error when none of the flags are enabled', async () => {
17+
const typeDefs = parse(`
18+
directive @ff(flags: [String!]!, softFail: Boolean = false) on FIELD_DEFINITION
19+
type Query {
20+
flaggedFeature: String @ff(flags: ["SOME_FLAG", "SOME_OTHER_FLAG"])
21+
}
22+
`);
23+
const resolvers = {
24+
Query: {
25+
flaggedFeature: () => 'experimental content',
26+
},
27+
};
28+
vi.mocked(isFeatureEnabled).mockReturnValue(false);
29+
30+
let schema = makeExecutableSchema({ typeDefs, resolvers });
31+
schema = makeFeatureFlagDirectiveTransformer()(schema);
32+
33+
const result = await graphql({ schema, source: '{ flaggedFeature }' });
34+
35+
expect(result.errors).not.toBeUndefined();
36+
expect(result.errors?.[0].message).toMatch(/You are not allowed to do this/i);
37+
expect(result.errors?.[0].extensions?.code).toMatch(/FORBIDDEN_ACCESS/i);
38+
});
39+
40+
it('calls the resolver when one of the flags is enabled', async () => {
41+
const typeDefs = parse(`
42+
directive @ff(flags: [String!]!, softFail: Boolean = false) on FIELD_DEFINITION
43+
type Query {
44+
flaggedFeature: String @ff(flags: ["SOME_FLAG", "SOME_OTHER_FLAG"])
45+
}
46+
`);
47+
const resolvers = {
48+
Query: {
49+
flaggedFeature: () => 'experimental content',
50+
},
51+
};
52+
vi.mocked(isFeatureEnabled).mockImplementation((flag: string) => {
53+
return flag === 'SOME_FLAG';
54+
});
55+
56+
let schema = makeExecutableSchema({ typeDefs, resolvers });
57+
schema = makeFeatureFlagDirectiveTransformer()(schema);
58+
59+
const result = await graphql({ schema, source: '{ flaggedFeature }' });
60+
61+
expect(result.data?.flaggedFeature).toBe('experimental content');
62+
});
63+
64+
it('returns null when none of the flags are enabled and softFail is set', async () => {
65+
const typeDefs = parse(`
66+
directive @ff(flags: [String!]!, softFail: Boolean = false) on FIELD_DEFINITION
67+
type Query {
68+
flaggedFeature: String @ff(flags: ["SOME_FLAG"], softFail: true)
69+
}
70+
`);
71+
const resolvers = {
72+
Query: {
73+
flaggedFeature: () => 'experimental content',
74+
},
75+
};
76+
vi.mocked(isFeatureEnabled).mockReturnValue(false);
77+
78+
let schema = makeExecutableSchema({ typeDefs, resolvers });
79+
schema = makeFeatureFlagDirectiveTransformer()(schema);
80+
81+
const result = await graphql({ schema, source: '{ flaggedFeature }' });
82+
83+
expect(result.errors).toBeUndefined();
84+
expect(result.data?.flaggedFeature).toBeNull();
85+
});
86+
});

0 commit comments

Comments
 (0)