Revert "feat(authz): shared authz pipeline (#4151)" (#4241)

This reverts commit cb8aa31b66.
This commit is contained in:
Chuck Driesler
2025-03-21 14:41:17 +00:00
committed by GitHub
parent 0781a4f58c
commit 66da283a79
47 changed files with 66 additions and 1370 deletions
-27
View File
@@ -1,27 +0,0 @@
import { getStreamFactory } from '@/modules/core/repositories/streams'
import { defineLoaders } from '@/modules/loaders'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { db } from '@/db/knex'
import { getUserServerRoleFactory } from '@/modules/shared/repositories/acl'
export const defineModuleLoaders = () => {
const getStream = getStreamFactory({ db })
const getUserServerRole = getUserServerRoleFactory({ db })
defineLoaders({
getEnv: getFeatureFlags,
getProject: async ({ projectId }) => {
const project = await getStream({ streamId: projectId })
if (!project) return null
return { ...project, projectId: project.id }
},
getProjectRole: async ({ userId, projectId }) => {
const project = await getStream({ streamId: projectId, userId })
return project?.role ?? null
},
getServerRole: async ({ userId }) => {
const role = await getUserServerRole({ userId })
return role ?? null
}
})
}
@@ -81,6 +81,7 @@ import { collectAndValidateCoreTargetsFactory } from '@/modules/serverinvites/se
import { createAndSendInviteFactory } from '@/modules/serverinvites/services/creation'
import { inviteUsersToProjectFactory } from '@/modules/serverinvites/services/projectInviteManagement'
import { authorizeResolver, validateScopes } from '@/modules/shared'
import { throwForNotHavingServerRole } from '@/modules/shared/authz'
import { getEventBus } from '@/modules/shared/services/eventBus'
import {
filteredSubscribe,
@@ -88,9 +89,6 @@ import {
UserSubscriptions
} from '@/modules/shared/utils/subscriptions'
import { has } from 'lodash'
import { throwUncoveredError } from '@speckle/shared'
import { ForbiddenError } from '@/modules/shared/errors'
import { Authz } from '@speckle/shared'
const getServerInfo = getServerInfoFactory({ db })
const getUsers = getUsersFactory({ db })
@@ -179,31 +177,28 @@ const getUserStreamsCount = getUserStreamsCountFactory({ db })
export = {
Query: {
async project(_parent, args, context) {
const canQuery = await context.authPolicies.project.canQuery({
projectId: args.id,
const getStream = getStreamFactory({ db })
const stream = await getStream({
streamId: args.id,
userId: context.userId
})
if (!canQuery.authorized) {
switch (canQuery.error.code) {
case Authz.ProjectNotFoundError.code:
throw new StreamNotFoundError()
case Authz.ProjectNoAccessError.code:
case Authz.WorkspaceNoAccessError.code:
case Authz.WorkspaceSsoSessionInvalidError.code:
throw new ForbiddenError(canQuery.error.message)
default:
throwUncoveredError(canQuery.error)
}
if (!stream) {
throw new StreamNotFoundError('Project not found')
}
const project = await getStream({ streamId: args.id })
await authorizeResolver(
context.userId,
args.id,
Roles.Stream.Reviewer,
context.resourceAccessRules
)
if (!project?.isPublic || !project.isDiscoverable) {
if (!stream.isPublic) {
await throwForNotHavingServerRole(context, Roles.Server.Guest)
validateScopes(context.scopes, Scopes.Streams.Read)
}
return project
return stream
}
},
Mutation: {
-3
View File
@@ -23,7 +23,6 @@ import { reportSubscriptionEventsFactory } from '@/modules/core/events/subscript
import { getEventBus } from '@/modules/shared/services/eventBus'
import { publish } from '@/modules/shared/utils/subscriptions'
import { getStreamCollaboratorsFactory } from '@/modules/core/repositories/streams'
import { defineModuleLoaders } from '@/modules/core/authz'
let stopTestSubs: (() => void) | undefined = undefined
@@ -88,8 +87,6 @@ const coreModule: SpeckleModule<{
getStreamCollaborators: getStreamCollaboratorsFactory({ db })
})()
}
defineModuleLoaders()
},
async shutdown() {
await shutdownResultListener()
+16 -10
View File
@@ -3,14 +3,11 @@ import {
UserStreamRole
} from '@/modules/shared/domain/rolesAndScopes/types'
import { Roles } from '@/modules/core/helpers/mainConstants'
import { RoleInfo } from '@speckle/shared'
import { pick } from 'lodash'
// Conventions:
// "weight: 1000" => resource owner
// "weight: 100" => resource viewer / basic user
// Anything in between 100 and 1000 can be used for escalating privileges.
const keysToPick = ['weight', 'description'] as const
const coreUserRoles: Array<UserServerRole | UserStreamRole> = [
/**
@@ -18,16 +15,19 @@ const coreUserRoles: Array<UserServerRole | UserStreamRole> = [
*/
{
name: Roles.Server.Admin,
...pick(RoleInfo.Server[Roles.Server.Admin], keysToPick),
description:
'Holds supreme autocratic authority, not restricted by written laws, legislature, or customs.',
resourceTarget: 'server',
aclTableName: 'server_acl',
weight: 1000,
public: false
},
{
name: Roles.Server.User,
...pick(RoleInfo.Server[Roles.Server.User], keysToPick),
description: 'Has normal access to the server.',
resourceTarget: 'server',
aclTableName: 'server_acl',
weight: 100,
public: false
},
// TODO: should this be dynamically pushed if guest role is enabled?
@@ -36,16 +36,18 @@ const coreUserRoles: Array<UserServerRole | UserStreamRole> = [
// can leave the guest users in a broken state
{
name: Roles.Server.Guest,
...pick(RoleInfo.Server[Roles.Server.Guest], keysToPick),
description: 'Has limited access to the server.',
resourceTarget: 'server',
aclTableName: 'server_acl',
weight: 50,
public: false
},
{
name: Roles.Server.ArchivedUser,
...pick(RoleInfo.Server[Roles.Server.ArchivedUser], keysToPick),
description: 'No longer has access to the server.',
resourceTarget: 'server',
aclTableName: 'server_acl',
weight: 10,
public: false
},
/**
@@ -53,23 +55,27 @@ const coreUserRoles: Array<UserServerRole | UserStreamRole> = [
*/
{
name: Roles.Stream.Owner,
...pick(RoleInfo.Stream[Roles.Stream.Owner], keysToPick),
description: 'Owners have full access, including deletion rights & access control.',
resourceTarget: 'streams',
aclTableName: 'stream_acl',
weight: 1000,
public: true
},
{
name: Roles.Stream.Contributor,
...pick(RoleInfo.Stream[Roles.Stream.Contributor], keysToPick),
description:
'Contributors can create new branches and commits, but they cannot edit stream details or manage collaborators.',
resourceTarget: 'streams',
aclTableName: 'stream_acl',
weight: 500,
public: true
},
{
name: Roles.Stream.Reviewer,
...pick(RoleInfo.Stream[Roles.Stream.Reviewer], keysToPick),
description: 'Reviewers can only view (read) the data from this stream.',
resourceTarget: 'streams',
aclTableName: 'stream_acl',
weight: 100,
public: true
}
]
-3
View File
@@ -24,7 +24,6 @@ import { AppMocksConfig } from '@/modules/mocks'
import { SpeckleModuleMocksConfig } from '@/modules/shared/helpers/mocks'
import { LogicError } from '@/modules/shared/errors'
import type { Registry } from 'prom-client'
import { validateLoaders } from '@/modules/loaders'
/**
* Cached speckle module requires
@@ -128,8 +127,6 @@ export const init = async (params: { app: Express; metricsRegister: Registry })
await module.finalize?.({ app, isInitial, metricsRegister })
}
validateLoaders()
hasInitializationOccurred = true
}
-51
View File
@@ -1,51 +0,0 @@
import { LoaderConfigurationError } from '@/modules/shared/errors'
import { Authz } from '@speckle/shared'
let cachedLoaders: Partial<Authz.AuthCheckContextLoaders> = {}
const loaderKeys: (keyof Authz.AuthCheckContextLoaders)[] = [
'getEnv',
'getProject',
'getProjectRole',
'getServerRole',
'getWorkspace',
'getWorkspaceRole',
'getWorkspaceSsoProvider',
'getWorkspaceSsoSession'
]
export const defineLoaders = (
loaders: Partial<Authz.AuthCheckContextLoaders>
): void => {
for (const key of Object.keys(loaders)) {
if (!loaderKeys.includes(key as keyof Authz.AuthCheckContextLoaders)) {
throw new LoaderConfigurationError(
`Attempted to define loader with unknown key: ${key}`
)
}
}
cachedLoaders = {
...cachedLoaders,
...loaders
}
}
const isValidLoaders = (
loaders: Partial<Authz.AuthCheckContextLoaders>
): loaders is Authz.AuthCheckContextLoaders => {
return loaderKeys.every((key) => !!loaders[key])
}
export const validateLoaders = () => {
if (!isValidLoaders(cachedLoaders)) {
throw new LoaderConfigurationError()
}
}
export const getLoaders = (): Authz.AuthCheckContextLoaders => {
if (!isValidLoaders(cachedLoaders)) {
throw new LoaderConfigurationError('Attempted to reference invalid loaders.')
}
return cachedLoaders
}
@@ -140,22 +140,5 @@ export class DatabaseError<I extends Info = Info> extends EnvironmentResourceErr
}
}
export class LoaderConfigurationError extends BaseError {
static code = 'LOADER_CONFIGURATION_ERROR'
static defaultMessage = 'Error while initializing authz loaders'
static statusCode = 500
constructor(message?: string) {
super(message)
}
}
export class LoaderUnsupportedError extends BaseError {
static code = 'LOADER_UNSUPPORTED_ERROR'
static defaultMessage =
'Cannot invoke loader given current server configuration. Check environment variables.'
static statusCode = 500
}
export { BaseError }
export type { Info }
@@ -3,8 +3,7 @@ import type {
Optional,
MaybeNullOrUndefined,
MaybeAsync,
MaybeFalsy,
Authz
MaybeFalsy
} from '@speckle/shared'
import type { RequestDataLoaders } from '@/modules/core/loaders'
import type { AuthContext } from '@/modules/shared/authz'
@@ -53,7 +52,6 @@ export type SpeckleModule<T extends Record<string, unknown> = Record<string, unk
export type GraphQLContext = BaseContext &
AuthContext & {
authPolicies: Authz.AuthPolices
/**
* Request-scoped GraphQL dataloaders
* @see https://github.com/graphql/dataloader
@@ -24,7 +24,7 @@ import {
MaybeNullOrUndefined,
Nullable
} from '@/modules/shared/helpers/typeHelper'
import { Authz, Optional, wait } from '@speckle/shared'
import { Optional, wait } from '@speckle/shared'
import { mixpanel } from '@/modules/shared/utils/mixpanel'
import * as Observability from '@speckle/shared/dist/commonjs/observability/index.js'
import { pino } from 'pino'
@@ -48,7 +48,6 @@ import { getTokenAppInfoFactory } from '@/modules/auth/repositories/apps'
import { getUserRoleFactory } from '@/modules/core/repositories/users'
import { UserInputError } from '@/modules/core/errors/userinput'
import compression from 'compression'
import { getLoaders } from '@/modules/loaders'
export const authMiddlewareCreator = (
steps: AuthPipelineFunction[]
@@ -221,14 +220,11 @@ export async function buildContext({
await wait(delay)
}
const authPolicies = Authz.authPoliciesFactory(getLoaders())
// Adding request data loaders
return await addLoadersToCtx(
{
...ctx,
log,
authPolicies
log
},
{ cleanLoadersEarly }
)
@@ -1,36 +0,0 @@
import { db } from '@/db/knex'
import { defineLoaders } from '@/modules/loaders'
import {
getUserSsoSessionFactory,
getWorkspaceSsoProviderRecordFactory
} from '@/modules/workspaces/repositories/sso'
import {
getWorkspaceFactory,
getWorkspaceRoleForUserFactory
} from '@/modules/workspaces/repositories/workspaces'
export const defineModuleLoaders = () => {
defineLoaders({
getWorkspace: getWorkspaceFactory({ db }),
getWorkspaceRole: async ({ userId, workspaceId }) => {
const role = await getWorkspaceRoleForUserFactory({ db })({
userId,
workspaceId
})
return role?.role ?? null
},
getWorkspaceSsoSession: async ({ userId, workspaceId }) => {
const ssoSession = await getUserSsoSessionFactory({ db })({
userId,
workspaceId
})
return ssoSession ?? null
},
getWorkspaceSsoProvider: async ({ workspaceId }) => {
const ssoProvider = await getWorkspaceSsoProviderRecordFactory({ db })({
workspaceId
})
return ssoProvider ?? null
}
})
}
@@ -10,7 +10,6 @@ import { initializeEventListenersFactory } from '@/modules/workspaces/events/eve
import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense'
import { getSsoRouter } from '@/modules/workspaces/rest/sso'
import { InvalidLicenseError } from '@/modules/gatekeeper/errors/license'
import { defineModuleLoaders } from '@/modules/workspaces/authz'
const { FF_WORKSPACES_MODULE_ENABLED, FF_WORKSPACES_SSO_ENABLED } = getFeatureFlags()
@@ -45,8 +44,6 @@ const workspacesModule: SpeckleModule = {
quitListeners = initializeEventListenersFactory({ db })()
}
await Promise.all([initScopes(), initRoles()])
defineModuleLoaders()
},
shutdown() {
if (!FF_WORKSPACES_MODULE_ENABLED) return
+7 -7
View File
@@ -1,30 +1,30 @@
import { UserWorkspaceRole } from '@/modules/shared/domain/rolesAndScopes/types'
import { Roles, RoleInfo } from '@speckle/shared'
import { pick } from 'lodash'
import { Roles } from '@speckle/shared'
const aclTableName = 'workspace_acl'
const resourceTarget = 'workspaces'
const keysToPick = ['weight', 'description'] as const
export const workspaceRoles: UserWorkspaceRole[] = [
{
name: Roles.Workspace.Admin,
...pick(RoleInfo.Workspace[Roles.Workspace.Admin], keysToPick),
description: 'Has root on the workspace',
weight: 1000,
public: true,
resourceTarget,
aclTableName
},
{
name: Roles.Workspace.Member,
...pick(RoleInfo.Workspace[Roles.Workspace.Member], keysToPick),
description: 'A regular member of the workspace',
weight: 100,
public: true,
resourceTarget,
aclTableName
},
{
name: Roles.Workspace.Guest,
...pick(RoleInfo.Workspace[Roles.Workspace.Guest], keysToPick),
description: 'An external guest member of the workspace with limited rights',
weight: 50,
public: true,
resourceTarget,
aclTableName
@@ -155,23 +155,19 @@ describe('Workspace SSO', () => {
const resA = await memberApollo.execute(GetWorkspaceDocument, {
workspaceId: testWorkspaceWithSso.id
})
expect(resA).to.haveGraphQLErrors({ message: 'gql-sso-workspace' })
expect(resA).to.haveGraphQLErrors({
code: 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
})
const resB = await memberApollo.execute(GetWorkspaceProjectsDocument, {
id: testWorkspaceWithSso.id
})
expect(resB).to.haveGraphQLErrors({ message: 'gql-sso-workspace' })
expect(resB).to.haveGraphQLErrors({
code: 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
})
const resC = await memberApollo.execute(GetProjectDocument, {
id: testWorkspaceWithSsoProjectId
})
expect(resC).to.haveGraphQLErrors({ message: 'SSO session is invalid' })
for (const res of [resA, resB, resC]) {
expect(res).to.haveGraphQLErrors({ message: 'gql-sso-workspace' })
expect(res).to.haveGraphQLErrors({
code: 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR'
})
}
})
it('should allow limited access to workspace memberships', async () => {
@@ -1,19 +0,0 @@
import { defineLoaders } from '@/modules/loaders'
import { LoaderUnsupportedError } from '@/modules/shared/errors'
export const defineModuleLoaders = () => {
defineLoaders({
getWorkspace: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceRole: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceSsoSession: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceSsoProvider: async () => {
throw new LoaderUnsupportedError()
}
})
}
@@ -1,8 +1,6 @@
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { defineModuleLoaders } from '@/modules/workspacesCore/authz'
import { moduleLogger } from '@/observability/logging'
export const init: SpeckleModule['init'] = () => {
moduleLogger.info('⚒️ Init workspaces core module')
defineModuleLoaders()
}
-4
View File
@@ -7,7 +7,6 @@ import { addLoadersToCtx } from '@/modules/shared/middleware'
import { Roles } from '@/modules/core/helpers/mainConstants'
import {
AllScopes,
Authz,
buildManualPromise,
ensureError,
MaybeAsync,
@@ -34,7 +33,6 @@ import { PingPongDocument } from '@/test/graphql/generated/graphql'
import { BaseError } from '@/modules/shared/errors'
import EventEmitter from 'eventemitter2'
import { expectToThrow } from '@/test/assertionHelper'
import { getLoaders } from '@/modules/loaders'
type TypedGraphqlResponse<R = Record<string, any>> = GraphQLResponse<R>
@@ -118,7 +116,6 @@ export const createTestContext = async (
scopes: [],
stream: undefined,
err: undefined,
authPolicies: Authz.authPoliciesFactory(getLoaders()),
...(ctx || {})
})
@@ -132,7 +129,6 @@ export const createAuthedTestContext = async (
role: Roles.Server.User,
token: 'asd',
scopes: AllScopes,
authPolicies: Authz.authPoliciesFactory(getLoaders()),
...(ctxOverrides || {})
})
+1 -2
View File
@@ -19,7 +19,7 @@
"lint:tsc": "tsc --noEmit",
"lint": "yarn lint:eslint && yarn lint:tsc",
"lint:ci": "yarn lint:tsc",
"test": "vitest src/authz/policies/canQueryProject.spec.ts",
"test": "vitest",
"test:single-run": "vitest run"
},
"sideEffects": false,
@@ -56,7 +56,6 @@
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"crypto-random-string": "^5.0.0",
"eslint": "^9.4.0",
"eslint-config-prettier": "^9.1.0",
"knex": "^2.5.1",
@@ -1,63 +0,0 @@
import { describe, expect, it } from 'vitest'
import { requireExactProjectVisibility } from './projects.js'
import cryptoRandomString from 'crypto-random-string'
import { Project } from '../domain/projects/types.js'
describe('requireExactProjectVisibility returns a function, that', () => {
it('throws if project does not exist', async () => {
const test = requireExactProjectVisibility({
loaders: {
getProject: () => Promise.resolve(null)
}
})
await expect(
test({
projectVisibility: 'linkShareable',
projectId: cryptoRandomString({ length: 9 })
})
).rejects.toThrow()
})
it('correctly asserts link shareable projects', async () => {
const result = await requireExactProjectVisibility({
loaders: {
getProject: () =>
Promise.resolve({
isDiscoverable: true
} as Project)
}
})({
projectVisibility: 'linkShareable',
projectId: cryptoRandomString({ length: 9 })
})
expect(result).toEqual(true)
})
it('correctly asserts public projects', async () => {
const result = await requireExactProjectVisibility({
loaders: {
getProject: () =>
Promise.resolve({
isPublic: true
} as Project)
}
})({
projectVisibility: 'public',
projectId: cryptoRandomString({ length: 9 })
})
expect(result).toEqual(true)
})
it('correct asserts private projects', async () => {
const result = await requireExactProjectVisibility({
loaders: {
getProject: () =>
Promise.resolve({
isDiscoverable: false,
isPublic: false
} as Project)
}
})({
projectVisibility: 'private',
projectId: cryptoRandomString({ length: 9 })
})
expect(result).toEqual(true)
})
})
@@ -1,42 +0,0 @@
import { StreamRoles, throwUncoveredError } from '../../core/index.js'
import { AuthCheckContext } from '../domain/loaders.js'
import { isMinimumProjectRole } from '../domain/projects/logic.js'
import { ProjectVisibility } from '../domain/projects/types.js'
export const requireExactProjectVisibility =
({ loaders }: AuthCheckContext<'getProject'>) =>
async (args: {
projectVisibility: ProjectVisibility
projectId: string
}): Promise<boolean> => {
const { projectId, projectVisibility } = args
const project = await loaders.getProject({ projectId })
if (!project) throw new Error(`Project not found`)
switch (projectVisibility) {
case 'linkShareable':
return project.isDiscoverable === true
case 'public':
return project.isPublic === true
case 'private':
return project.isPublic !== true && project.isDiscoverable !== true
default:
throwUncoveredError(projectVisibility)
}
}
export const requireMinimumProjectRole =
({ loaders }: AuthCheckContext<'getProjectRole'>) =>
async (args: {
userId: string
projectId: string
role: StreamRoles
}): Promise<boolean> => {
const { userId, projectId, role: requiredProjectRole } = args
const userProjectRole = await loaders.getProjectRole({ userId, projectId })
return userProjectRole
? isMinimumProjectRole(userProjectRole, requiredProjectRole)
: false
}
@@ -1,39 +0,0 @@
import { describe, expect, it } from 'vitest'
import { requireExactServerRole } from './serverRole.js'
import cryptoRandomString from 'crypto-random-string'
describe('requireExactServerRole returns a function, that', () => {
it('returns false for mismatch roles', async () => {
const result = await requireExactServerRole({
loaders: {
getServerRole: () => Promise.resolve('server:user')
}
})({
userId: cryptoRandomString({ length: 9 }),
role: 'server:admin'
})
expect(result).toEqual(false)
})
it('returns false for users without roles', async () => {
const result = await requireExactServerRole({
loaders: {
getServerRole: () => Promise.resolve(null)
}
})({
userId: cryptoRandomString({ length: 9 }),
role: 'server:admin'
})
expect(result).toEqual(false)
})
it('returns true for matching roles', async () => {
const result = await requireExactServerRole({
loaders: {
getServerRole: () => Promise.resolve('server:admin')
}
})({
userId: cryptoRandomString({ length: 9 }),
role: 'server:admin'
})
expect(result).toEqual(true)
})
})
@@ -1,12 +0,0 @@
import { ServerRoles } from '../../core/constants.js'
import { AuthCheckContext } from '../domain/loaders.js'
export const requireExactServerRole =
({ loaders }: AuthCheckContext<'getServerRole'>) =>
async (args: { userId: string; role: ServerRoles }): Promise<boolean> => {
const { userId, role: requiredServerRole } = args
const userServerRole = await loaders.getServerRole({ userId })
return userServerRole === requiredServerRole
}
@@ -1,82 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
requireAnyWorkspaceRole,
requireMinimumWorkspaceRole
} from './workspaceRole.js'
import cryptoRandomString from 'crypto-random-string'
describe('requireAnyWorkspaceRole returns a function, that', () => {
it('returns false if the user has no role', async () => {
const result = await requireAnyWorkspaceRole({
loaders: {
getWorkspaceRole: () => Promise.resolve(null)
}
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
})
expect(result).toEqual(false)
})
it('returns true if the user has a role', async () => {
const result = await requireAnyWorkspaceRole({
loaders: {
getWorkspaceRole: () => Promise.resolve('workspace:member')
}
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
})
expect(result).toEqual(true)
})
})
describe('requireMinimumWorkspaceRole returns a function, that', () => {
it('returns false if user does not have a role', async () => {
const result = await requireMinimumWorkspaceRole({
loaders: {
getWorkspaceRole: () => Promise.resolve(null)
}
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 }),
role: 'workspace:member'
})
expect(result).toEqual(false)
})
it('returns false if user is below target role', async () => {
const result = await requireMinimumWorkspaceRole({
loaders: {
getWorkspaceRole: () => Promise.resolve('workspace:member')
}
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 }),
role: 'workspace:admin'
})
expect(result).toEqual(false)
})
it('returns true if user matches target role', async () => {
const result = await requireMinimumWorkspaceRole({
loaders: {
getWorkspaceRole: () => Promise.resolve('workspace:member')
}
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 }),
role: 'workspace:member'
})
expect(result).toEqual(true)
})
it('returns true if user exceeds target role', async () => {
const result = await requireMinimumWorkspaceRole({
loaders: {
getWorkspaceRole: () => Promise.resolve('workspace:admin')
}
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 }),
role: 'workspace:member'
})
expect(result).toEqual(true)
})
})
@@ -1,29 +0,0 @@
import { WorkspaceRoles } from '../../core/constants.js'
import { AuthCheckContext } from '../domain/loaders.js'
import { isMinimumWorkspaceRole } from '../domain/workspaces/logic.js'
export const requireAnyWorkspaceRole =
({ loaders }: AuthCheckContext<'getWorkspaceRole'>) =>
async (args: { userId: string; workspaceId: string }): Promise<boolean> => {
const { userId, workspaceId } = args
const userWorkspaceRole = await loaders.getWorkspaceRole({ userId, workspaceId })
return userWorkspaceRole !== null
}
export const requireMinimumWorkspaceRole =
({ loaders }: AuthCheckContext<'getWorkspaceRole'>) =>
async (args: {
userId: string
workspaceId: string
role: WorkspaceRoles
}): Promise<boolean> => {
const { userId, workspaceId, role: requiredWorkspaceRole } = args
const userWorkspaceRole = await loaders.getWorkspaceRole({ userId, workspaceId })
return userWorkspaceRole
? isMinimumWorkspaceRole(userWorkspaceRole, requiredWorkspaceRole)
: false
}
@@ -1,63 +0,0 @@
import { describe, expect, it } from 'vitest'
import { requireValidWorkspaceSsoSession } from './workspaceSso.js'
import cryptoRandomString from 'crypto-random-string'
describe('requireValidWorkspaceSsoSession returns a function, that', () => {
it('returns false if user does not have an SSO session', async () => {
const result = await requireValidWorkspaceSsoSession({
loaders: {
getWorkspaceSsoSession: () => Promise.resolve(null)
}
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
})
expect(result).toBe(false)
})
it('returns false if user has an expired sso session', async () => {
const userId = cryptoRandomString({ length: 9 })
const providerId = cryptoRandomString({ length: 9 })
const workspaceId = cryptoRandomString({ length: 9 })
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() - 1)
const result = await requireValidWorkspaceSsoSession({
loaders: {
getWorkspaceSsoSession: () =>
Promise.resolve({
userId,
providerId,
validUntil
})
}
})({
userId,
workspaceId
})
expect(result).toBe(false)
})
it('returns true if user has a valid sso session', async () => {
const userId = cryptoRandomString({ length: 9 })
const providerId = cryptoRandomString({ length: 9 })
const workspaceId = cryptoRandomString({ length: 9 })
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 1)
const result = await requireValidWorkspaceSsoSession({
loaders: {
getWorkspaceSsoSession: () =>
Promise.resolve({
userId,
providerId,
validUntil
})
}
})({
userId,
workspaceId
})
expect(result).toBe(true)
})
})
@@ -1,17 +0,0 @@
import { AuthCheckContext } from '../domain/loaders.js'
export const requireValidWorkspaceSsoSession =
({ loaders }: AuthCheckContext<'getWorkspaceSsoSession'>) =>
async (args: { userId: string; workspaceId: string }): Promise<boolean> => {
const { userId, workspaceId } = args
const workspaceSsoSession = await loaders.getWorkspaceSsoSession({
userId,
workspaceId
})
const isExpiredSession =
new Date().getTime() > (workspaceSsoSession?.validUntil?.getTime() ?? 0)
return !!workspaceSsoSession && !isExpiredSession
}
@@ -1,19 +0,0 @@
type AuthSuccess = {
authorized: true
}
export type AuthFailure<T> = {
authorized: false
error: T
}
export type AuthResult<T> = AuthSuccess | AuthFailure<T>
export const authorized = (): AuthSuccess => ({
authorized: true
})
export const unauthorized = <T>(error: T): AuthFailure<T> => ({
authorized: false,
error
})
@@ -1,3 +0,0 @@
import { ServerRoles } from '../../../core/constants.js'
export type GetServerRole = (args: { userId: string }) => Promise<ServerRoles | null>
@@ -1,36 +0,0 @@
type AuthError<ErrorCode extends string> = {
code: ErrorCode
message: string
}
export const defineAuthError = <ErrorCode extends string>(params: {
code: ErrorCode
message: string
}): AuthError<ErrorCode> => {
const { code, message } = params
return {
code,
message
}
}
export const ProjectNotFoundError = defineAuthError({
code: 'ProjectNotFound',
message: 'Project not found'
})
export const ProjectNoAccessError = defineAuthError({
code: 'ProjectNoAccess',
message: 'You do not have access to the project'
})
export const WorkspaceNoAccessError = defineAuthError({
code: 'WorkspaceNoAccess',
message: 'You do not have access to the workspace'
})
export const WorkspaceSsoSessionInvalidError = defineAuthError({
code: 'WorkspaceSsoSessionInvalid',
message: 'Your workspace SSO session is invalid'
})
@@ -1,24 +0,0 @@
import { GetServerRole } from './core/operations.js'
import { GetProject, GetProjectRole } from './projects/operations.js'
import {
GetEnv,
GetWorkspace,
GetWorkspaceRole,
GetWorkspaceSsoProvider,
GetWorkspaceSsoSession
} from './workspaces/operations.js'
export type AuthCheckContext<LoaderKeys extends keyof AuthCheckContextLoaders> = {
loaders: Pick<AuthCheckContextLoaders, LoaderKeys>
}
export type AuthCheckContextLoaders = {
getEnv: GetEnv
getProject: GetProject
getProjectRole: GetProjectRole
getServerRole: GetServerRole
getWorkspace: GetWorkspace
getWorkspaceRole: GetWorkspaceRole
getWorkspaceSsoProvider: GetWorkspaceSsoProvider
getWorkspaceSsoSession: GetWorkspaceSsoSession
}
@@ -1,3 +0,0 @@
export type ProjectContext = { projectId: string }
export type UserContext = { userId?: string }
@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isMinimumProjectRole } from './logic.js'
describe('project logic', () => {
describe('isMinimumProjectRole', () => {
it('returns true if role has bigger weight than target role', () => {
expect(isMinimumProjectRole('stream:owner', 'stream:contributor')).toBe(true)
})
it('returns true if role has the same weight as the target role', () => {
expect(isMinimumProjectRole('stream:contributor', 'stream:contributor')).toBe(
true
)
})
it('returns false if role has smaller weight than target role', () => {
expect(isMinimumProjectRole('stream:reviewer', 'stream:contributor')).toBe(false)
})
})
})
@@ -1,10 +0,0 @@
import { StreamRoles, RoleInfo } from '../../../core/constants.js'
export const isMinimumProjectRole = (
role: StreamRoles,
targetRole: StreamRoles
): boolean => {
const roleWeight = RoleInfo.Stream[role].weight
const targetRoleWeight = RoleInfo.Stream[targetRole].weight
return roleWeight >= targetRoleWeight
}
@@ -1,10 +0,0 @@
import { StreamRoles } from '../../../core/constants.js'
import { Project } from './types.js'
// TODO: this should probably just throw an error if the project doesn't exist
export type GetProject = (args: { projectId: string }) => Promise<Project | null>
export type GetProjectRole = (args: {
userId: string
projectId: string
}) => Promise<StreamRoles | null>
@@ -1,8 +0,0 @@
export type Project = {
// TODO: Deprecated field?
isDiscoverable: boolean
isPublic: boolean
workspaceId: string | null
}
export type ProjectVisibility = 'public' | 'linkShareable' | 'private'
@@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isMinimumWorkspaceRole } from './logic.js'
describe('project logic', () => {
describe('isMinimumProjectRole', () => {
it('returns true if role has bigger weight than target role', () => {
expect(isMinimumWorkspaceRole('workspace:admin', 'workspace:member')).toBe(true)
})
it('returns true if role has the same weight as the target role', () => {
expect(isMinimumWorkspaceRole('workspace:admin', 'workspace:admin')).toBe(true)
})
it('returns false if role has smaller weight than target role', () => {
expect(isMinimumWorkspaceRole('workspace:guest', 'workspace:admin')).toBe(false)
})
})
})
@@ -1,11 +0,0 @@
import { RoleInfo, WorkspaceRoles } from '../../../core/constants.js'
export const isMinimumWorkspaceRole = (
role: WorkspaceRoles,
targetRole: WorkspaceRoles
): boolean => {
const roleWeight = RoleInfo.Workspace[role].weight
const targetRoleWeight = RoleInfo.Workspace[targetRole].weight
return roleWeight >= targetRoleWeight
}
@@ -1,21 +0,0 @@
import { WorkspaceRoles } from '../../../core/constants.js'
import { FeatureFlags } from '../../../environment/index.js'
import { Workspace, WorkspaceSsoProvider, WorkspaceSsoSession } from './types.js'
export type GetWorkspace = (args: { workspaceId: string }) => Promise<Workspace | null>
export type GetWorkspaceRole = (args: {
userId: string
workspaceId: string
}) => Promise<WorkspaceRoles | null>
export type GetWorkspaceSsoProvider = (args: {
workspaceId: string
}) => Promise<WorkspaceSsoProvider | null>
export type GetWorkspaceSsoSession = (args: {
userId: string
workspaceId: string
}) => Promise<WorkspaceSsoSession | null>
export type GetEnv = () => FeatureFlags
@@ -1,13 +0,0 @@
export type Workspace = {
id: string
}
export type WorkspaceSsoProvider = {
providerId: string
}
export type WorkspaceSsoSession = {
userId: string
providerId: string
validUntil: Date
}
-4
View File
@@ -1,4 +0,0 @@
export { authPoliciesFactory, AuthPolices } from './policies/index.js'
export { AuthCheckContextLoaders } from './domain/loaders.js'
export * from './domain/errors.js'
@@ -1,410 +0,0 @@
import { describe, expect, it, assert } from 'vitest'
import { canQueryProjectPolicyFactory } from './canQueryProject.js'
import { parseFeatureFlags } from '../../environment/index.js'
import crs from 'crypto-random-string'
import { merge } from 'lodash'
import { Project } from '../domain/projects/types.js'
import { Roles } from '../../core/constants.js'
import { ProjectNoAccessError, ProjectNotFoundError } from '../domain/errors.js'
const fakeGetFactory =
<T extends Record<string, unknown>>(defaults: T) =>
(overrides?: Partial<T>) =>
(): Promise<T> => {
if (overrides) {
return Promise.resolve(merge(defaults, overrides))
}
return Promise.resolve(defaults)
}
const getProjectFake = fakeGetFactory<Project>({
isPublic: false,
isDiscoverable: false,
workspaceId: null
})
const canQueryProjectArgs = () => {
const projectId = crs({ length: 10 })
const userId = crs({ length: 10 })
return { projectId, userId }
}
describe('canQueryProjectPolicyFactory creates a function, that handles ', () => {
describe('project not found', () => {
it('by returning project no access', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () => parseFeatureFlags({}),
getProject: () => Promise.resolve(null),
getProjectRole: () => {
assert.fail()
},
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(false)
if (!canQuery.authorized) {
expect(canQuery.error.code).toBe(ProjectNotFoundError.code)
}
})
})
describe('project visibility', () => {
it('allows anyone on a public project', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () => parseFeatureFlags({}),
getProject: getProjectFake({ isPublic: true }),
getProjectRole: () => {
assert.fail()
},
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
})
it('allows anyone on a linkShareable project', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () => parseFeatureFlags({}),
getProject: getProjectFake({ isDiscoverable: true }),
getProjectRole: () => {
assert.fail()
},
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
})
})
describe('project roles', () => {
it.each(Object.values(Roles.Stream))(
'allows access to private projects with role %',
async (role) => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () => parseFeatureFlags({}),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getProjectRole: () => Promise.resolve(role),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
}
)
it('does not allow access to private projects without a project role', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () => parseFeatureFlags({}),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getProjectRole: () => Promise.resolve(null),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(false)
if (!canQuery.authorized) {
expect(canQuery.error.code).toBe(ProjectNoAccessError.code)
}
})
})
describe('admin override', () => {
it('allows server admins without project roles on private projects if admin override is enabled', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' }),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getServerRole: () => Promise.resolve(Roles.Server.Admin),
getProjectRole: () => {
assert.fail()
},
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
})
it('does not allow server admins without project roles on private projects if admin override is disabled', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'false' }),
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
getServerRole: () => Promise.resolve(Roles.Server.Admin),
getProjectRole: () => {
return Promise.resolve(null)
},
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(false)
if (!canQuery.authorized) {
expect(canQuery.error.code).toBe(ProjectNoAccessError.code)
}
})
})
describe('the workspace world', () => {
it('does not check workspace rules if the workspaces module is not enabled', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }),
getProject: getProjectFake({
isDiscoverable: false,
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: () => Promise.resolve('stream:contributor'),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => {
assert.fail()
},
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
})
it('does not allow project access without a workspace role', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
isDiscoverable: false,
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: () => Promise.resolve('stream:contributor'),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => Promise.resolve(null),
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(false)
})
it('allows project access via workspace role if user does not have project role', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
isDiscoverable: false,
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: () => Promise.resolve(null),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => Promise.resolve('workspace:admin'),
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => Promise.resolve(null)
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
})
it('does not check SSO sessions if user is workspace guest', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
isDiscoverable: false,
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: () => Promise.resolve('stream:contributor'),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => Promise.resolve('workspace:guest'),
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => {
assert.fail()
}
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
})
it('does not check SSO sessions if workspace does not have it enabled', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
isDiscoverable: false,
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: () => Promise.resolve('stream:contributor'),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => Promise.resolve('workspace:member'),
getWorkspaceSsoSession: () => {
assert.fail()
},
getWorkspaceSsoProvider: () => Promise.resolve(null)
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
})
it('does not allow project access if SSO session is missing', async () => {
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
isDiscoverable: false,
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: () => Promise.resolve('stream:contributor'),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => Promise.resolve('workspace:member'),
getWorkspaceSsoSession: () => Promise.resolve(null),
getWorkspaceSsoProvider: () => Promise.resolve({ providerId: 'foo' })
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(false)
})
it('does not allow project access if SSO session is expired or invalid', async () => {
const date = new Date()
date.setDate(date.getDate() - 1)
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
isDiscoverable: false,
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: () => Promise.resolve('stream:contributor'),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => Promise.resolve('workspace:member'),
getWorkspaceSsoSession: () =>
Promise.resolve({ validUntil: date, userId: 'foo', providerId: 'foo' }),
getWorkspaceSsoProvider: () => Promise.resolve({ providerId: 'foo' })
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(false)
})
it('allows project access if SSO session is valid', async () => {
const date = new Date()
date.setDate(date.getDate() + 1)
const canQueryProject = canQueryProjectPolicyFactory({
getEnv: () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
isDiscoverable: false,
isPublic: false,
workspaceId: crs({ length: 10 })
}),
getProjectRole: () => Promise.resolve('stream:contributor'),
getServerRole: () => {
assert.fail()
},
getWorkspaceRole: () => Promise.resolve('workspace:member'),
getWorkspaceSsoSession: () =>
Promise.resolve({ validUntil: date, userId: 'foo', providerId: 'foo' }),
getWorkspaceSsoProvider: () => Promise.resolve({ providerId: 'foo' })
})
const canQuery = await canQueryProject(canQueryProjectArgs())
expect(canQuery.authorized).toBe(true)
})
})
})
@@ -1,142 +0,0 @@
import {
requireAnyWorkspaceRole,
requireMinimumWorkspaceRole
} from '../checks/workspaceRole.js'
import { AuthResult, authorized, unauthorized } from '../domain/authResult.js'
import {
requireExactProjectVisibility,
requireMinimumProjectRole
} from '../checks/projects.js'
import { AuthCheckContextLoaders } from '../domain/loaders.js'
import { ProjectContext, UserContext } from '../domain/policies.js'
import { requireExactServerRole } from '../checks/serverRole.js'
import { requireValidWorkspaceSsoSession } from '../checks/workspaceSso.js'
import { Roles } from '../../core/constants.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
WorkspaceNoAccessError,
WorkspaceSsoSessionInvalidError
} from '../domain/errors.js'
export const canQueryProjectPolicyFactory =
(
loaders: Pick<
AuthCheckContextLoaders,
| 'getEnv'
| 'getProject'
| 'getProjectRole'
| 'getServerRole'
| 'getWorkspaceRole'
| 'getWorkspaceSsoProvider'
| 'getWorkspaceSsoSession'
>
) =>
async ({
userId,
projectId
}: UserContext & ProjectContext): Promise<
AuthResult<
| typeof ProjectNotFoundError
| typeof ProjectNoAccessError
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionInvalidError
>
> => {
const { FF_ADMIN_OVERRIDE_ENABLED, FF_WORKSPACES_MODULE_ENABLED } = loaders.getEnv()
const project = await loaders.getProject({ projectId })
// hiding the project not found, to stop id brute force lookups
if (!project) return unauthorized(ProjectNotFoundError)
// All users may read public projects
const isPublicResult = await requireExactProjectVisibility({ loaders })({
projectId,
projectVisibility: 'public'
})
if (isPublicResult) {
return authorized()
}
// All users may read link-shareable projects
const isLinkShareableResult = await requireExactProjectVisibility({ loaders })({
projectId,
projectVisibility: 'linkShareable'
})
if (isLinkShareableResult) {
return authorized()
}
// From this point on, you cannot pass as an unknown user
if (!userId) {
return unauthorized(ProjectNoAccessError)
}
// When G O D M O D E is enabled
if (FF_ADMIN_OVERRIDE_ENABLED) {
// Server admins may read all project data
const isServerAdminResult = await requireExactServerRole({ loaders })({
userId,
role: Roles.Server.Admin
})
if (isServerAdminResult) {
return authorized()
}
}
const { workspaceId } = project
// When a project belongs to a workspace
if (FF_WORKSPACES_MODULE_ENABLED && !!workspaceId) {
// User must have a workspace role to read project data
const hasWorkspaceRoleResult = await requireAnyWorkspaceRole({ loaders })({
userId,
workspaceId
})
if (!hasWorkspaceRoleResult) {
// Should we hide the fact, the project is in a workspace?
return unauthorized(WorkspaceNoAccessError)
}
const hasMinimumMemberRole = await requireMinimumWorkspaceRole({
loaders
})({
userId,
workspaceId,
role: 'workspace:member'
})
if (hasMinimumMemberRole) {
const workspaceSsoProvider = await loaders.getWorkspaceSsoProvider({
workspaceId
})
if (!!workspaceSsoProvider) {
// Member and admin user must have a valid SSO session to read project data
const hasValidSsoSessionResult = await requireValidWorkspaceSsoSession({
loaders
})({
userId,
workspaceId
})
if (!hasValidSsoSessionResult) {
return unauthorized(WorkspaceSsoSessionInvalidError)
}
}
// Workspace members get to go through without an explicit project role
return authorized()
} else {
// just fall through to the generic project role check for workspace:guest-s
}
}
// User must have at least stream reviewer role to read project data
const hasMinimumProjectRoleResult = await requireMinimumProjectRole({ loaders })({
userId,
projectId,
role: 'stream:reviewer'
})
if (hasMinimumProjectRoleResult) {
return authorized()
}
return unauthorized(ProjectNoAccessError)
}
@@ -1,10 +0,0 @@
import { AuthCheckContextLoaders } from '../domain/loaders.js'
import { canQueryProjectPolicyFactory } from './canQueryProject.js'
export const authPoliciesFactory = (loaders: AuthCheckContextLoaders) => ({
project: {
canQuery: canQueryProjectPolicyFactory(loaders)
}
})
export type AuthPolices = ReturnType<typeof authPoliciesFactory>
+10 -20
View File
@@ -28,57 +28,47 @@ export const RoleInfo = Object.freeze(<const>{
Stream: {
[Roles.Stream.Owner]: {
title: 'Owner',
description: 'Can edit project, including settings, collaborators and all models',
weight: 1000
description: 'Can edit project, including settings, collaborators and all models'
},
[Roles.Stream.Contributor]: {
title: 'Contributor',
description: 'Can create models, publish model versions, and comment',
weight: 500
description: 'Can create models, publish model versions, and comment'
},
[Roles.Stream.Reviewer]: {
title: 'Reviewer',
description: 'Can view models, load model data, and comment',
weight: 100
description: 'Can view models, load model data, and comment'
}
},
Server: {
[Roles.Server.Admin]: {
title: 'Admin',
description: 'Can edit server, including settings, users and all projects',
weight: 1000
description: 'Can edit server, including settings, users and all projects'
},
[Roles.Server.User]: {
title: 'User',
description: 'Can create and own projects',
weight: 100
description: 'Can create and own projects'
},
[Roles.Server.Guest]: {
title: 'Guest',
description: "Can contribute to projects they're invited to",
weight: 50
description: "Can contribute to projects they're invited to"
},
[Roles.Server.ArchivedUser]: {
title: 'Archived',
description: 'Can no longer access server',
weight: 10
description: 'Can no longer access server'
}
},
Workspace: {
[Roles.Workspace.Admin]: {
title: 'Admin',
description: 'Can edit workspace, including settings, members and all projects',
weight: 1000
description: 'Can edit workspace, including settings, members and all projects'
},
[Roles.Workspace.Member]: {
title: 'Member',
description: 'Can create and own projects',
weight: 100
description: 'Can create and own projects'
},
[Roles.Workspace.Guest]: {
title: 'Guest',
description: "Can contribute to projects they're invited to",
weight: 50
description: "Can contribute to projects they're invited to"
}
}
})
+6 -18
View File
@@ -6,18 +6,10 @@ const isDisableAllFFsMode = () =>
const isEnableAllFFsMode = () =>
['true', '1'].includes(process.env.ENABLE_ALL_FFS || '')
export const parseFeatureFlags = (
input: // | Record<string, string | undefined>
Partial<Record<keyof FeatureFlags, 'true' | 'false' | undefined>>
): FeatureFlags => {
const parseFeatureFlags = () => {
//INFO
// As a convention all feature flags should be prefixed with a FF_
const res = parseEnv(input, {
// Enables the admin override feature
FF_ADMIN_OVERRIDE_ENABLED: {
schema: z.boolean(),
defaults: { production: false, _: false }
},
const res = parseEnv(process.env, {
// Enables the automate module.
FF_AUTOMATE_MODULE_ENABLED: {
schema: z.boolean(),
@@ -101,10 +93,9 @@ export const parseFeatureFlags = (
return res
}
let parsedFlags: FeatureFlags | undefined
let parsedFlags: ReturnType<typeof parseFeatureFlags> | undefined
export type FeatureFlags = {
FF_ADMIN_OVERRIDE_ENABLED: boolean
export function getFeatureFlags(): {
FF_AUTOMATE_MODULE_ENABLED: boolean
FF_GENDOAI_MODULE_ENABLED: boolean
FF_WORKSPACES_MODULE_ENABLED: boolean
@@ -119,10 +110,7 @@ export type FeatureFlags = {
FF_OBJECTS_STREAMING_FIX: boolean
FF_MOVE_PROJECT_REGION_ENABLED: boolean
FF_NO_PERSONAL_EMAILS_ENABLED: boolean
}
export function getFeatureFlags(): FeatureFlags {
//@ts-expect-error this way, the parse function typing is a lot better
if (!parsedFlags) parsedFlags = parseFeatureFlags(process.env)
} {
if (!parsedFlags) parsedFlags = parseFeatureFlags()
return parsedFlags
}
-1
View File
@@ -3,7 +3,6 @@ export * as RichTextEditor from './rich-text-editor/index.js'
export * as SpeckleViewer from './viewer/index.js'
// export * as Environment from './environment/index.js' // Import from @speckle/shared/dist/...
export * as Automate from './automate/index.js'
export * as Authz from './authz/index.js'
export * from './core/index.js'
export * from './workspaces/index.js'
export * from './onboarding/index.js'
+1 -1
View File
@@ -11,7 +11,7 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"target": "es2019" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
-1
View File
@@ -16982,7 +16982,6 @@ __metadata:
"@types/ua-parser-js": "npm:^0.7.39"
"@typescript-eslint/eslint-plugin": "npm:^7.12.0"
"@typescript-eslint/parser": "npm:^7.12.0"
crypto-random-string: "npm:^5.0.0"
eslint: "npm:^9.4.0"
eslint-config-prettier: "npm:^9.1.0"
knex: "npm:^2.5.1"