Skip to content

Commit 76232df

Browse files
committed
Add SSO config support with admin-only GraphQL directive
Introduces a new @definedOnlyForAdmins directive to restrict certain fields to workspace admins, returning null for non-admins. Adds SSO configuration types, inputs, and resolvers to the workspace schema, including the sso field and updateWorkspaceSso mutation, both protected for admin access. Updates schema wiring to register the new directive and its transformer.
1 parent 1963a59 commit 76232df

File tree

4 files changed

+351
-0
lines changed

4 files changed

+351
-0
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { defaultFieldResolver, GraphQLSchema } from 'graphql';
2+
import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils';
3+
import { ResolverContextWithUser, UnknownGraphQLResolverResult } from '../types/graphql';
4+
import { ForbiddenError, UserInputError } from 'apollo-server-express';
5+
import WorkspaceModel from '../models/workspace';
6+
7+
/**
8+
* Check if user is admin of workspace
9+
* @param context - resolver context
10+
* @param workspaceId - workspace id to check
11+
* @returns true if user is admin, false otherwise
12+
*/
13+
async function isUserAdminOfWorkspace(context: ResolverContextWithUser, workspaceId: string): Promise<boolean> {
14+
try {
15+
const workspace = await context.factories.workspacesFactory.findById(workspaceId);
16+
17+
if (!workspace) {
18+
return false;
19+
}
20+
21+
const member = await workspace.getMemberInfo(context.user.id);
22+
23+
if (!member || WorkspaceModel.isPendingMember(member)) {
24+
return false;
25+
}
26+
27+
return member.isAdmin || false;
28+
} catch {
29+
return false;
30+
}
31+
}
32+
33+
/**
34+
* Defines directive for fields that are only defined for admins
35+
* Returns null for non-admin users instead of throwing error
36+
*
37+
* Works with object fields where parent object has _id field (workspace id)
38+
*
39+
* Usage:
40+
* type Workspace {
41+
* sso: WorkspaceSsoConfig @definedOnlyForAdmins
42+
* }
43+
*/
44+
export default function definedOnlyForAdminsDirective(directiveName = 'definedOnlyForAdmins') {
45+
return {
46+
definedOnlyForAdminsDirectiveTypeDefs: `
47+
"""
48+
Field is only defined for admins. Returns null for non-admin users.
49+
Works with object fields where parent object has _id field (workspace id).
50+
"""
51+
directive @${directiveName} on FIELD_DEFINITION
52+
`,
53+
definedOnlyForAdminsDirectiveTransformer: (schema: GraphQLSchema) =>
54+
mapSchema(schema, {
55+
[MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => {
56+
const definedOnlyForAdminsDirective = getDirective(schema, fieldConfig, directiveName)?.[0];
57+
58+
if (definedOnlyForAdminsDirective) {
59+
const {
60+
resolve = defaultFieldResolver,
61+
} = fieldConfig;
62+
63+
/**
64+
* New field resolver that checks admin rights
65+
* @param resolverArgs - default GraphQL resolver args
66+
*/
67+
fieldConfig.resolve = async (...resolverArgs): UnknownGraphQLResolverResult => {
68+
const [parent, , context] = resolverArgs;
69+
70+
/**
71+
* Get workspace ID from parent object
72+
* Parent should have _id field (workspace)
73+
*/
74+
if (!parent || !parent._id) {
75+
return null;
76+
}
77+
78+
const workspaceId = parent._id.toString();
79+
80+
/**
81+
* Check if user is admin
82+
*/
83+
const isAdmin = await isUserAdminOfWorkspace(context, workspaceId);
84+
85+
if (!isAdmin) {
86+
return null;
87+
}
88+
89+
/**
90+
* Call original resolver
91+
*/
92+
return resolve(...resolverArgs);
93+
};
94+
}
95+
96+
return fieldConfig;
97+
},
98+
}),
99+
};
100+
}
101+

src/resolvers/workspace.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,61 @@ module.exports = {
329329
return true;
330330
},
331331

332+
/**
333+
* Update workspace SSO configuration (admin only)
334+
* Protected by @requireAdmin directive - admin check is done by directive
335+
* @param {ResolverObj} _obj - object that contains the result returned from the resolver on the parent field
336+
* @param {String} workspaceId - workspace ID
337+
* @param {Object} config - SSO configuration
338+
* @param {ContextFactories} factories - factories for working with models
339+
* @return {Promise<Boolean>}
340+
*/
341+
async updateWorkspaceSso(_obj, { workspaceId, config }, { factories }) {
342+
const workspace = await factories.workspacesFactory.findById(workspaceId);
343+
344+
if (!workspace) {
345+
throw new UserInputError('Workspace not found');
346+
}
347+
348+
/**
349+
* Validate configuration
350+
*/
351+
if (config.enabled && !config.saml) {
352+
throw new UserInputError('SAML configuration is required when SSO is enabled');
353+
}
354+
355+
/**
356+
* Prepare update data
357+
* If enabled=false, preserve existing SSO config and only update enabled flag
358+
* If enabled=true, update full SSO configuration
359+
*/
360+
const updateData = {
361+
...workspace,
362+
sso: config.enabled ? {
363+
enabled: config.enabled,
364+
enforced: config.enforced || false,
365+
type: 'saml',
366+
saml: {
367+
idpEntityId: config.saml.idpEntityId,
368+
ssoUrl: config.saml.ssoUrl,
369+
x509Cert: config.saml.x509Cert,
370+
nameIdFormat: config.saml.nameIdFormat,
371+
attributeMapping: {
372+
email: config.saml.attributeMapping.email,
373+
name: config.saml.attributeMapping.name,
374+
},
375+
},
376+
} : workspace.sso ? {
377+
...workspace.sso,
378+
enabled: false,
379+
} : undefined,
380+
};
381+
382+
await workspace.updateWorkspace(updateData);
383+
384+
return true;
385+
},
386+
332387
/**
333388
* Change workspace plan for default plan mutation implementation
334389
*
@@ -493,6 +548,28 @@ module.exports = {
493548

494549
return new PlanModel(plan);
495550
},
551+
552+
/**
553+
* SSO configuration (admin only)
554+
* Protected by @definedOnlyForAdmins directive - returns null for non-admin users
555+
* @param {WorkspaceDBScheme} workspace - result from resolver above (parent workspace object)
556+
* @param _args - empty list of args
557+
* @param {UserInContext} context - resolver context
558+
* @returns {Promise<WorkspaceSsoConfig | null>}
559+
*/
560+
async sso(workspace, _args, { factories }) {
561+
/**
562+
* Get workspace model to access SSO config
563+
* Admin check is done by @definedOnlyForAdmins directive
564+
*/
565+
const workspaceModel = await factories.workspacesFactory.findById(workspace._id.toString());
566+
567+
if (!workspaceModel) {
568+
return null;
569+
}
570+
571+
return workspaceModel.sso || null;
572+
},
496573
},
497574

498575
/**

src/schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import uploadImageDirective from './directives/uploadImageDirective';
99
import allowAnonDirective from './directives/allowAnon';
1010
import requireAdminDirective from './directives/requireAdmin';
1111
import requireUserInWorkspaceDirective from './directives/requireUserInWorkspace';
12+
import definedOnlyForAdminsDirective from './directives/definedOnlyForAdmins';
1213

1314
const { renameFromDirectiveTypeDefs, renameFromDirectiveTransformer } = renameFromDirective();
1415
const { defaultValueDirectiveTypeDefs, defaultValueDirectiveTransformer } = defaultValueDirective();
@@ -17,6 +18,7 @@ const { uploadImageDirectiveTypeDefs, uploadImageDirectiveTransformer } = upload
1718
const { allowAnonDirectiveTypeDefs, allowAnonDirectiveTransformer } = allowAnonDirective();
1819
const { requireAdminDirectiveTypeDefs, requireAdminDirectiveTransformer } = requireAdminDirective();
1920
const { requireUserInWorkspaceDirectiveTypeDefs, requireUserInWorkspaceDirectiveTransformer } = requireUserInWorkspaceDirective();
21+
const { definedOnlyForAdminsDirectiveTypeDefs, definedOnlyForAdminsDirectiveTransformer } = definedOnlyForAdminsDirective();
2022

2123
let schema = makeExecutableSchema({
2224
typeDefs: mergeTypeDefs([
@@ -27,6 +29,7 @@ let schema = makeExecutableSchema({
2729
allowAnonDirectiveTypeDefs,
2830
requireAdminDirectiveTypeDefs,
2931
requireUserInWorkspaceDirectiveTypeDefs,
32+
definedOnlyForAdminsDirectiveTypeDefs,
3033
...typeDefs,
3134
]),
3235
resolvers,
@@ -39,5 +42,6 @@ schema = uploadImageDirectiveTransformer(schema);
3942
schema = requireAdminDirectiveTransformer(schema);
4043
schema = allowAnonDirectiveTransformer(schema);
4144
schema = requireUserInWorkspaceDirectiveTransformer(schema);
45+
schema = definedOnlyForAdminsDirectiveTransformer(schema);
4246

4347
export default schema;

0 commit comments

Comments
 (0)