diff --git a/packages/server/modules/core/helpers/token.ts b/packages/server/modules/core/helpers/token.ts index 2b08be6b1..140f02f9e 100644 --- a/packages/server/modules/core/helpers/token.ts +++ b/packages/server/modules/core/helpers/token.ts @@ -4,6 +4,7 @@ import { } from '@/modules/core/domain/tokens/types' import { TokenCreateError } from '@/modules/core/errors/user' import { TokenResourceAccessRecord } from '@/modules/core/helpers/types' +import { UserRole } from '@/modules/shared/domain/rolesAndScopes/types' import { MaybeNullOrUndefined, Nullable, Optional, Scopes } from '@speckle/shared' import { differenceBy } from 'lodash' @@ -25,7 +26,7 @@ export const resourceAccessRuleToIdentifier = ( } export const roleResourceTypeToTokenResourceType = ( - type: RoleResourceTargets + type: RoleResourceTargets | UserRole['resourceTarget'] ): Nullable => { switch (type) { case RoleResourceTargets.Streams: diff --git a/packages/server/modules/shared/domain/operations.ts b/packages/server/modules/shared/domain/operations.ts new file mode 100644 index 000000000..fd2d8e606 --- /dev/null +++ b/packages/server/modules/shared/domain/operations.ts @@ -0,0 +1,28 @@ +import { ServerAcl, StreamAcl } from '@/modules/core/dbSchema' +import { TokenResourceIdentifier } from '@/modules/core/domain/tokens/types' +import { WorkspaceAcl } from '@/modules/workspacesCore/helpers/db' +import { + AvailableRoles, + MaybeNullOrUndefined, + Optional, + ServerRoles +} from '@speckle/shared' + +export type GetUserAclRole = (params: { + aclTableName: typeof ServerAcl.name | typeof StreamAcl.name | typeof WorkspaceAcl.name + userId: string + resourceId: string +}) => Promise> + +export type GetUserServerRole = (params: { + userId: string +}) => Promise> + +export type ValidateScopes = (scopes: Optional, scope: string) => void + +export type AuthorizeResolver = ( + userId: MaybeNullOrUndefined, + resourceId: string, + requiredRole: AvailableRoles, + userResourceAccessLimits: MaybeNullOrUndefined +) => Promise diff --git a/packages/server/modules/shared/index.js b/packages/server/modules/shared/index.js deleted file mode 100644 index 4f01eb6e2..000000000 --- a/packages/server/modules/shared/index.js +++ /dev/null @@ -1,138 +0,0 @@ -const knex = require(`@/db/knex`) -const { - pubsub, - StreamSubscriptions, - CommitSubscriptions, - BranchSubscriptions -} = require('@/modules/shared/utils/subscriptions') -const { Roles } = require('@speckle/shared') -const { adminOverrideEnabled } = require('@/modules/shared/helpers/envHelper') - -const { ServerAcl: ServerAclSchema } = require('@/modules/core/dbSchema') -const { getRolesFactory } = require('@/modules/shared/repositories/roles') -const { - roleResourceTypeToTokenResourceType, - isResourceAllowed -} = require('@/modules/core/helpers/token') -const db = require('@/db/knex') -const { ForbiddenError } = require('@/modules/shared/errors') -const ServerAcl = () => ServerAclSchema.knex() - -/** - * Validates the scope against a list of scopes of the current session. - * @param {string[]|undefined} scopes - * @param {string} scope - * @return {Promise} - */ -async function validateScopes(scopes, scope) { - const errMsg = `Your auth token does not have the required scope${ - scope?.length ? ': ' + scope + '.' : '.' - }` - - if (!scopes) throw new ForbiddenError(errMsg, { scope }) - if (scopes.indexOf(scope) === -1 && scopes.indexOf('*') === -1) - throw new ForbiddenError(errMsg, { scope }) -} - -const getUserAclEntry = async ({ aclTableName, userId, resourceId }) => { - if (!userId) { - return null - } - - const query = { userId } - - // Different acl tables have different names for the resource id column - switch (aclTableName) { - case 'server_acl': { - // No mutation necessary - break - } - case 'stream_acl': { - query.resourceId = resourceId - break - } - case 'workspace_acl': { - query.workspaceId = resourceId - break - } - } - - return await knex(aclTableName).select('*').where(query).first() -} - -/** - * Checks the userId against the resource's acl. - * @param {string | null | undefined} userId - * @param {string} resourceId - * @param {string} requiredRole - * @param {import('@/modules/core/domain/tokens/types').TokenResourceIdentifier[] | undefined | null} userResourceAccessLimits - */ -async function authorizeResolver( - userId, - resourceId, - requiredRole, - userResourceAccessLimits -) { - userId = userId || null - const roles = await getRolesFactory({ db })() - - // TODO: Cache these results with a TTL of 1 mins or so, it's pointless to query the db every time we get a ping. - - const role = roles.find((r) => r.name === requiredRole) - if (!role) throw new ForbiddenError('Unknown role: ' + requiredRole) - - const resourceRuleType = roleResourceTypeToTokenResourceType(role.resourceTarget) - const isResourceLimited = - resourceRuleType && - !isResourceAllowed({ - resourceId, - resourceType: resourceRuleType, - resourceAccessRules: userResourceAccessLimits - }) - if (isResourceLimited) { - throw new ForbiddenError('You are not authorized to access this resource.') - } - - if (adminOverrideEnabled()) { - const serverRoles = await ServerAcl().select('role').where({ userId }) - if (serverRoles.map((r) => r.role).includes(Roles.Server.Admin)) return requiredRole - } - - if (role.resourceTarget === 'streams') { - try { - const { isPublic } = await knex(role.resourceTarget) - .select('isPublic') - .where({ id: resourceId }) - .first() - if (isPublic && role.weight < 200) return true - } catch { - throw new ForbiddenError( - `Resource of type ${role.resourceTarget} with ${resourceId} not found` - ) - } - } - - const userAclEntry = await getUserAclEntry({ - aclTableName: role.aclTableName, - userId, - resourceId - }) - - if (!userAclEntry) { - throw new ForbiddenError('You are not authorized to access this resource.') - } - - userAclEntry.role = roles.find((r) => r.name === userAclEntry.role) - - if (userAclEntry.role.weight >= role.weight) return userAclEntry.role.name - throw new ForbiddenError('You are not authorized.') -} - -module.exports = { - validateScopes, - authorizeResolver, - pubsub, - StreamPubsubEvents: StreamSubscriptions, - CommitPubsubEvents: CommitSubscriptions, - BranchPubsubEvents: BranchSubscriptions -} diff --git a/packages/server/modules/shared/index.ts b/packages/server/modules/shared/index.ts new file mode 100644 index 000000000..653cf8d52 --- /dev/null +++ b/packages/server/modules/shared/index.ts @@ -0,0 +1,34 @@ +import { db } from '@/db/knex' +import { getStream } from '@/modules/core/repositories/streams' +import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' +import { + getUserAclRoleFactory, + getUserServerRoleFactory +} from '@/modules/shared/repositories/acl' +import { getRolesFactory } from '@/modules/shared/repositories/roles' +import { + authorizeResolverFactory, + validateScopesFactory +} from '@/modules/shared/services/auth' +import { + pubsub, + StreamSubscriptions, + CommitSubscriptions, + BranchSubscriptions +} from '@/modules/shared/utils/subscriptions' + +export { + pubsub, + StreamSubscriptions as StreamPubsubEvents, + CommitSubscriptions as CommitPubsubEvents, + BranchSubscriptions as BranchPubsubEvents +} + +export const validateScopes = validateScopesFactory() +export const authorizeResolver = authorizeResolverFactory({ + getRoles: getRolesFactory({ db }), + adminOverrideEnabled, + getUserServerRole: getUserServerRoleFactory({ db }), + getStream, + getUserAclRole: getUserAclRoleFactory({ db }) +}) diff --git a/packages/server/modules/shared/repositories/acl.ts b/packages/server/modules/shared/repositories/acl.ts new file mode 100644 index 000000000..e73ea5fb5 --- /dev/null +++ b/packages/server/modules/shared/repositories/acl.ts @@ -0,0 +1,53 @@ +import { ServerAcl } from '@/modules/core/dbSchema' +import { ServerAclRecord, StreamAclRecord } from '@/modules/core/helpers/types' +import { GetUserAclRole, GetUserServerRole } from '@/modules/shared/domain/operations' +import { WorkspaceAcl as WorkspaceAclRecord } from '@/modules/workspacesCore/domain/types' +import { AvailableRoles, Optional, ServerRoles } from '@speckle/shared' +import { Knex } from 'knex' + +const tables = { + serverAcl: (db: Knex) => db(ServerAcl.name) +} + +export const getUserAclRoleFactory = + (deps: { db: Knex }): GetUserAclRole => + async (params) => { + const { aclTableName, userId, resourceId } = params + if (!userId) { + return null + } + + const query: { userId: string; resourceId?: string; workspaceId?: string } = { + userId + } + + // Different acl tables have different names for the resource id column + switch (aclTableName) { + case 'server_acl': { + // No mutation necessary + break + } + case 'stream_acl': { + query.resourceId = resourceId + break + } + case 'workspace_acl': { + query.workspaceId = resourceId + break + } + } + + const ret: Optional = + await deps.db(aclTableName).select('*').where(query).first() + return ret?.role as Optional + } + +export const getUserServerRoleFactory = + (deps: { db: Knex }): GetUserServerRole => + async (params) => { + const acl = await tables + .serverAcl(deps.db) + .where(ServerAcl.col.userId, params.userId) + .first() + return acl?.role as Optional + } diff --git a/packages/server/modules/shared/services/auth.ts b/packages/server/modules/shared/services/auth.ts new file mode 100644 index 000000000..c53f7c022 --- /dev/null +++ b/packages/server/modules/shared/services/auth.ts @@ -0,0 +1,99 @@ +import { + isResourceAllowed, + RoleResourceTargets, + roleResourceTypeToTokenResourceType +} from '@/modules/core/helpers/token' +import { getStream } from '@/modules/core/repositories/streams' +import { + AuthorizeResolver, + GetUserAclRole, + GetUserServerRole, + ValidateScopes +} from '@/modules/shared/domain/operations' +import { GetRoles } from '@/modules/shared/domain/rolesAndScopes/operations' +import { ForbiddenError } from '@/modules/shared/errors' +import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' +import { Roles } from '@speckle/shared' + +/** + * Validates the scope against a list of scopes of the current session. + */ +export const validateScopesFactory = (): ValidateScopes => (scopes, scope) => { + const errMsg = `Your auth token does not have the required scope${ + scope?.length ? ': ' + scope + '.' : '.' + }` + + if (!scopes) throw new ForbiddenError(errMsg, { info: { scope } }) + if (scopes.indexOf(scope) === -1 && scopes.indexOf('*') === -1) + throw new ForbiddenError(errMsg, { info: { scope } }) +} + +/** + * Checks the userId against the resource's acl. + */ +export const authorizeResolverFactory = + (deps: { + getRoles: GetRoles + adminOverrideEnabled: typeof adminOverrideEnabled + getUserServerRole: GetUserServerRole + getStream: typeof getStream + getUserAclRole: GetUserAclRole + }): AuthorizeResolver => + async (userId, resourceId, requiredRole, userResourceAccessLimits) => { + userId = userId || null + const roles = await deps.getRoles() + + // TODO: Cache these results with a TTL of 1 mins or so, it's pointless to query the db every time we get a ping. + + const role = roles.find((r) => r.name === requiredRole) + if (!role) throw new ForbiddenError('Unknown role: ' + requiredRole) + + const resourceRuleType = roleResourceTypeToTokenResourceType(role.resourceTarget) + const isResourceLimited = + resourceRuleType && + !isResourceAllowed({ + resourceId, + resourceType: resourceRuleType, + resourceAccessRules: userResourceAccessLimits + }) + if (isResourceLimited) { + throw new ForbiddenError('You are not authorized to access this resource.') + } + + if (deps.adminOverrideEnabled() && userId) { + const serverRole = await deps.getUserServerRole({ userId }) + if (serverRole === Roles.Server.Admin) return + } + + if (role.resourceTarget === RoleResourceTargets.Streams) { + try { + const stream = await deps.getStream({ + userId: userId || undefined, + streamId: resourceId + }) + const isPublic = !!stream?.isPublic + if (isPublic && role.weight < 200) return + } catch { + throw new ForbiddenError( + `Resource of type ${role.resourceTarget} with ${resourceId} not found` + ) + } + } + + const userAclRole = userId + ? await deps.getUserAclRole({ + aclTableName: role.aclTableName, + userId, + resourceId + }) + : null + + if (!userAclRole) { + throw new ForbiddenError('You are not authorized to access this resource.') + } + + const fullRole = roles.find((r) => r.name === userAclRole) + + if (fullRole && fullRole.weight >= role.weight) return + throw new ForbiddenError('You are not authorized.') + } diff --git a/packages/server/modules/workspaces/helpers/db.ts b/packages/server/modules/workspaces/helpers/db.ts index dc0321614..5caf86c65 100644 --- a/packages/server/modules/workspaces/helpers/db.ts +++ b/packages/server/modules/workspaces/helpers/db.ts @@ -1,32 +1 @@ -import { buildTableHelper } from '@/modules/core/dbSchema' - -export const Workspaces = buildTableHelper('workspaces', [ - 'id', - 'name', - 'slug', - 'description', - 'createdAt', - 'updatedAt', - 'logo', - 'defaultLogoIndex', - 'defaultProjectRole', - 'domainBasedMembershipProtectionEnabled', - 'discoverabilityEnabled' -]) - -export const WorkspaceAcl = buildTableHelper('workspace_acl', [ - 'userId', - 'role', - 'workspaceId', - 'createdAt' -]) - -export const WorkspaceDomains = buildTableHelper('workspace_domains', [ - 'id', - 'workspaceId', - 'domain', - 'createdAt', - 'updatedAt', - 'createdByUserId', - 'verified' -]) +export * from '@/modules/workspacesCore/helpers/db' diff --git a/packages/server/modules/workspacesCore/helpers/db.ts b/packages/server/modules/workspacesCore/helpers/db.ts new file mode 100644 index 000000000..dc0321614 --- /dev/null +++ b/packages/server/modules/workspacesCore/helpers/db.ts @@ -0,0 +1,32 @@ +import { buildTableHelper } from '@/modules/core/dbSchema' + +export const Workspaces = buildTableHelper('workspaces', [ + 'id', + 'name', + 'slug', + 'description', + 'createdAt', + 'updatedAt', + 'logo', + 'defaultLogoIndex', + 'defaultProjectRole', + 'domainBasedMembershipProtectionEnabled', + 'discoverabilityEnabled' +]) + +export const WorkspaceAcl = buildTableHelper('workspace_acl', [ + 'userId', + 'role', + 'workspaceId', + 'createdAt' +]) + +export const WorkspaceDomains = buildTableHelper('workspace_domains', [ + 'id', + 'workspaceId', + 'domain', + 'createdAt', + 'updatedAt', + 'createdByUserId', + 'verified' +])