diff --git a/packages/server/modules/core/tests/generic.spec.js b/packages/server/modules/core/tests/generic.spec.js index 9485f32e5..d5a20b633 100644 --- a/packages/server/modules/core/tests/generic.spec.js +++ b/packages/server/modules/core/tests/generic.spec.js @@ -2,6 +2,8 @@ const expect = require('chai').expect const { beforeEachContext } = require('@/test/hooks') +const { createStream } = require('@/modules/core/services/streams') +const { createUser } = require('@/modules/core/services/users') const { validateServerRole, @@ -9,6 +11,7 @@ const { authorizeResolver } = require('@/modules/shared') const { buildContext } = require('@/modules/shared/middleware') +const { ForbiddenError } = require('apollo-server-express') describe('Generic AuthN & AuthZ controller tests', () => { before(async () => { @@ -90,4 +93,102 @@ describe('Generic AuthN & AuthZ controller tests', () => { }) .catch((err) => expect('Unknown role: streams:read').to.equal(err.message)) }) + + describe('Authorize resolver ', () => { + const myStream = { + name: 'My Stream 2', + isPublic: true + } + const notMyStream = { + name: 'Not My Stream 1', + isPublic: false + } + const serverOwner = { + name: 'Itsa Me', + email: 'me@gmail.com', + password: 'sn3aky-1337-b1m' + } + const otherGuy = { + name: 'Some Other DUde', + email: 'otherguy@gmail.com', + password: 'sn3aky-1337-b1m' + } + + before(async function () { + // Seeding + await Promise.all([ + createUser(serverOwner).then((id) => (serverOwner.id = id)), + createUser(otherGuy).then((id) => (otherGuy.id = id)) + ]) + + await Promise.all([ + createStream({ ...myStream, ownerId: serverOwner.id }).then( + (id) => (myStream.id = id) + ), + createStream({ ...notMyStream, ownerId: otherGuy.id }).then( + (id) => (notMyStream.id = id) + ) + ]) + }) + + afterEach(() => { + process.env.ADMIN_OVERRIDE_ENABLED = 'false' + }) + it('should allow stream:owners to be stream:owners', async () => { + const role = await authorizeResolver( + serverOwner.id, + myStream.id, + 'stream:contributor' + ) + expect(role).to.equal('stream:owner') + }) + + it('should get the passed in role for server:admins if override enabled', async () => { + process.env.ADMIN_OVERRIDE_ENABLED = 'true' + const role = await authorizeResolver( + serverOwner.id, + myStream.id, + 'stream:contributor' + ) + expect(role).to.equal('stream:contributor') + }) + + it('should not allow server:admins to be anything if adminOverride is disabled', async () => { + try { + await authorizeResolver(serverOwner.id, notMyStream.id, 'stream:contributor') + throw 'This should have thrown' + } catch (e) { + expect(e instanceof ForbiddenError) + } + }) + + it('should allow server:admins to be anything if adminOverride is enabled', async () => { + process.env.ADMIN_OVERRIDE_ENABLED = 'true' + const role = await authorizeResolver( + serverOwner.id, + notMyStream.id, + 'stream:contributor' + ) + expect(role).to.equal('stream:contributor') + }) + + it('should not allow server:users to be anything if adminOverride is disabled', async () => { + try { + await authorizeResolver(otherGuy.id, myStream.id, 'stream:contributor') + throw 'This should have thrown' + } catch (e) { + expect(e instanceof ForbiddenError) + } + }) + + it('should not allow server:users to be anything if adminOverride is enabled', async () => { + process.env.ADMIN_OVERRIDE_ENABLED = 'true' + try { + await authorizeResolver(otherGuy.id, myStream.id, 'stream:contributor') + throw 'This should have thrown' + } catch (e) { + expect(e instanceof ForbiddenError) + } + }) + }) }) diff --git a/packages/server/modules/shared/authz.ts b/packages/server/modules/shared/authz.ts index 25b9eb8a4..c7e8c74e4 100644 --- a/packages/server/modules/shared/authz.ts +++ b/packages/server/modules/shared/authz.ts @@ -14,7 +14,7 @@ import { ContextError, BadRequestError } from '@/modules/shared/errors' -// import { getbAllRoles } from '../core/services/generic' +import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper' interface AuthResult { authorized: boolean @@ -220,6 +220,12 @@ export const contextRequiresStream = } } +export const allowForServerAdmins: AuthPipelineFunction = async ({ + context, + authResult +}) => + context.role === Roles.Server.Admin ? authSuccess(context) : { context, authResult } + export const allowForRegisteredUsersOnPublicStreamsEvenWithoutRole: AuthPipelineFunction = async ({ context, authResult }) => context.auth && context.stream?.isPublic @@ -269,3 +275,5 @@ export const streamReadPermissions = [ contextRequiresStream(getStream as StreamGetter), validateStreamRole({ requiredRole: Roles.Stream.Contributor }) ] + +if (adminOverrideEnabled()) streamReadPermissions.push(allowForServerAdmins) diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 098c64f74..5a62627f8 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -99,3 +99,7 @@ export function shouldDisableNotificationsConsumption() { export function isSSLServer() { return /^https:\/\//.test(getBaseUrl()) } + +export function adminOverrideEnabled() { + return process.env.ADMIN_OVERRIDE_ENABLED === 'true' +} diff --git a/packages/server/modules/shared/index.js b/packages/server/modules/shared/index.js index b1295bea4..81cda75db 100644 --- a/packages/server/modules/shared/index.js +++ b/packages/server/modules/shared/index.js @@ -3,6 +3,11 @@ const Redis = require('ioredis') const knex = require(`@/db/knex`) const { ForbiddenError, ApolloError } = require('apollo-server-express') const { RedisPubSub } = require('graphql-redis-subscriptions') +const { ServerAcl: ServerAclSchema } = require('@/modules/core/dbSchema') +const ServerAcl = () => ServerAclSchema.knex() + +const { Roles } = require('@speckle/shared') +const { adminOverrideEnabled } = require('@/modules/shared/helpers/envHelper') const StreamPubsubEvents = Object.freeze({ UserStreamAdded: 'USER_STREAM_ADDED', @@ -85,6 +90,11 @@ async function authorizeResolver(userId, resourceId, requiredRole) { if (!role) throw new ApolloError('Unknown role: ' + requiredRole) + if (adminOverrideEnabled()) { + const serverRoles = await ServerAcl().select('role').where({ userId }) + if (serverRoles.map((r) => r.role).includes(Roles.Server.Admin)) return requiredRole + } + try { const { isPublic } = await knex(role.resourceTarget) .select('isPublic') @@ -107,7 +117,7 @@ async function authorizeResolver(userId, resourceId, requiredRole) { userAclEntry.role = roles.find((r) => r.name === userAclEntry.role) if (userAclEntry.role.weight >= role.weight) return userAclEntry.role.name - else throw new ForbiddenError('You are not authorized.') + throw new ForbiddenError('You are not authorized.') } const Scopes = () => knex('scopes') @@ -122,10 +132,10 @@ async function registerOrUpdateScope(scope) { return } -const Roles = () => knex('user_roles') +const UserRoles = () => knex('user_roles') async function registerOrUpdateRole(role) { await knex.raw( - `${Roles() + `${UserRoles() .insert(role) .toString()} on conflict (name) do update set weight = ?, description = ?, "resourceTarget" = ? `, [role.weight, role.description, role.resourceTarget] diff --git a/packages/server/modules/shared/test/authz.spec.js b/packages/server/modules/shared/test/authz.spec.js index 3f755f76a..8b1d06e83 100644 --- a/packages/server/modules/shared/test/authz.spec.js +++ b/packages/server/modules/shared/test/authz.spec.js @@ -7,7 +7,8 @@ const { validateScope, contextRequiresStream, allowForAllRegisteredUsersOnPublicStreamsWithPublicComments, - allowForRegisteredUsersOnPublicStreamsEvenWithoutRole + allowForRegisteredUsersOnPublicStreamsEvenWithoutRole, + allowForServerAdmins } = require('@/modules/shared/authz') const { ForbiddenError: SFE, @@ -16,6 +17,7 @@ const { UnauthorizedError, ContextError } = require('@/modules/shared/errors') +const { Roles } = require('@speckle/shared') describe('AuthZ @shared', () => { describe('Auth pipeline', () => { @@ -267,6 +269,18 @@ describe('AuthZ @shared', () => { }) }) describe('Escape hatches', () => { + describe('Admin override', () => { + it('server:admins get authSuccess', async () => { + const input = { context: { role: Roles.Server.Admin }, authResult: 'fake' } + const result = await allowForServerAdmins(input) + expect(result).to.deep.equal(authSuccess(input.context)) + }) + it('server:users get the previous authResult', async () => { + const input = { context: { role: Roles.Server.User }, authResult: 'fake' } + const result = await allowForServerAdmins(input) + expect(result).to.deep.equal(input) + }) + }) describe('Allow for public stream no role', () => { it('not public stream, no auth returns same context ', async () => { const input = { context: 'dummy', authResult: 'fake' } diff --git a/utils/helm/speckle-server/templates/server/deployment.yml b/utils/helm/speckle-server/templates/server/deployment.yml index 00354e874..095ca87eb 100644 --- a/utils/helm/speckle-server/templates/server/deployment.yml +++ b/utils/helm/speckle-server/templates/server/deployment.yml @@ -133,6 +133,11 @@ spec: - name: DISABLE_FILE_UPLOADS value: "true" {{ end }} + + {{- if .Values.server.adminOverrideEnabled }} + - name: ADMIN_OVERRIDE_ENABLED + value: "true" + {{- end }} # *** S3 Object Storage *** diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index bb53a2a35..5b453febd 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -438,6 +438,14 @@ "description": "The number of instances of the Server pod to be deployed within the cluster.", "default": 1 }, + "logLevel": { + "type": "string", + "description": "The minimum level of logs which will be output" + }, + "adminOverrideEnabled": { + "type": "boolean", + "description": "Enables the server side admin authz override" + }, "sessionSecret": { "type": "object", "properties": { diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index 6fb001488..0a49860dd 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -364,6 +364,9 @@ server: ## @param server.logLevel The minimum level of logs which will be output. Suitable values are trace, debug, info, warn, error, fatal, or silent ## logLevel: 'info' + + ## @param server.adminOverrideEnabled Enables the server side admin authz override + adminOverrideEnabled: false sessionSecret: ## @param server.sessionSecret.secretName The name of the Kubernetes Secret containing the Session secret. This is a unique value (can be generated randomly). This is expected to be provided within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets ## diff --git a/utils/helm/test-values.yml b/utils/helm/test-values.yml index a9f06b013..7f2c3d401 100644 --- a/utils/helm/test-values.yml +++ b/utils/helm/test-values.yml @@ -14,6 +14,9 @@ s3: access_key: 'minioadmin' create_bucket: 'true' +server: + adminOverrideEnabled: true + cert_manager_issuer: ~ enable_prometheus_monitoring: true file_size_limit_mb: 300