diff --git a/packages/server/modules/core/authz/loaders/index.ts b/packages/server/modules/core/authz/loaders/index.ts index 47a4118e0..e802a23d6 100644 --- a/packages/server/modules/core/authz/loaders/index.ts +++ b/packages/server/modules/core/authz/loaders/index.ts @@ -11,7 +11,7 @@ export default defineModuleLoaders(async () => { const getUserServerRole = getUserServerRoleFactory({ db }) return { - getEnv: async () => ok(getFeatureFlags()), + getEnv: async () => getFeatureFlags(), getProject: async ({ projectId }) => { const project = await getStream({ streamId: projectId }) if (!project) return err(Authz.ProjectNotFoundError) diff --git a/packages/server/modules/core/graph/resolvers/projects.ts b/packages/server/modules/core/graph/resolvers/projects.ts index affc1c941..042e7fdc2 100644 --- a/packages/server/modules/core/graph/resolvers/projects.ts +++ b/packages/server/modules/core/graph/resolvers/projects.ts @@ -163,7 +163,7 @@ const cloneStream = cloneStreamFactory({ emitEvent: getEventBus().emit }) -// We want to read & write from main DB - this isn't occuring in a multi region workspace ctx +// We want to read & write from main DB - this isn't occurring in a multi region workspace ctx const createOnboardingStream = createOnboardingStreamFactory({ getOnboardingBaseProject: getOnboardingBaseProjectFactory({ getOnboardingBaseStream: getOnboardingBaseStreamFactory({ db }) @@ -192,6 +192,9 @@ export = { case Authz.WorkspaceNoAccessError.code: case Authz.WorkspaceSsoSessionInvalidError.code: throw new ForbiddenError(canQuery.error.message) + case Authz.ServerNoAccessError.code: + case Authz.ServerNoSessionError.code: + throw new ForbiddenError(canQuery.error.message) default: throwUncoveredError(canQuery.error) } diff --git a/packages/shared/src/authz/checks/projects.spec.ts b/packages/shared/src/authz/checks/projects.spec.ts index a4718b5ac..7ba319fa6 100644 --- a/packages/shared/src/authz/checks/projects.spec.ts +++ b/packages/shared/src/authz/checks/projects.spec.ts @@ -1,104 +1,65 @@ import { describe, expect, it } from 'vitest' -import { - requireExactProjectVisibilityFactory, - requireMinimumProjectRoleFactory -} from './projects.js' +import { isPubliclyReadableProject, hasMinimumProjectRole } from './projects.js' import cryptoRandomString from 'crypto-random-string' -import { Project } from '../domain/projects/types.js' -import { Roles, UncoveredError } from '../../core/index.js' +import { Roles } from '../../core/index.js' import { err, ok } from 'true-myth/result' -import { ProjectNotFoundError, ProjectRoleNotFoundError } from '../domain/authErrors.js' +import { + ProjectNoAccessError, + ProjectNotFoundError, + ProjectRoleNotFoundError, + WorkspaceSsoSessionInvalidError +} from '../domain/authErrors.js' +import { getProjectFake } from '../../tests/fakes.js' describe('project checks', () => { - describe('requireExactProjectVisibilityFactory returns a function, that', () => { - it('throws if project does not exist', async () => { - const requireExactProjectVisibility = requireExactProjectVisibilityFactory({ - loaders: { - getProject: () => Promise.resolve(err(ProjectNotFoundError)) - } - }) + describe('isPubliclyReadableProject returns a function, that', () => { + it('throws uncoveredError for unexpected loader errors', async () => { await expect( - requireExactProjectVisibility({ - projectVisibility: 'linkShareable', - projectId: cryptoRandomString({ length: 9 }) - }) - ).rejects.toThrow() + isPubliclyReadableProject({ + // @ts-expect-error deliberately testing an unexpeceted error type + getProject: async () => err(ProjectRoleNotFoundError) + })({ projectId: cryptoRandomString({ length: 10 }) }) + ).rejects.toThrowError(/Uncovered error/) }) - it('correctly asserts link shareable projects', async () => { - const result = await requireExactProjectVisibilityFactory({ - loaders: { - getProject: () => - Promise.resolve( - ok({ - isDiscoverable: true - } as Project) - ) - } - })({ - projectVisibility: 'linkShareable', - projectId: cryptoRandomString({ length: 9 }) - }) + it.each([ + ProjectNotFoundError, + ProjectNoAccessError, + WorkspaceSsoSessionInvalidError + ])('turns expected loader error $code into false ', async (loaderError) => { + const result = await isPubliclyReadableProject({ + getProject: async () => err(loaderError) + })({ projectId: cryptoRandomString({ length: 10 }) }) + expect(result).toEqual(false) + }) + it('returns true for public projects', async () => { + const result = await isPubliclyReadableProject({ + getProject: getProjectFake({ isPublic: true }) + })({ projectId: cryptoRandomString({ length: 10 }) }) expect(result).toEqual(true) }) - it('correctly asserts public projects', async () => { - const result = await requireExactProjectVisibilityFactory({ - loaders: { - getProject: () => - Promise.resolve( - ok({ - isPublic: true - } as Project) - ) - } - })({ - projectVisibility: 'public', - projectId: cryptoRandomString({ length: 9 }) - }) + it('returns true for discoverable projects', async () => { + const result = await isPubliclyReadableProject({ + getProject: getProjectFake({ isDiscoverable: true }) + })({ projectId: cryptoRandomString({ length: 10 }) }) expect(result).toEqual(true) }) - it('correctly asserts private projects', async () => { - const result = await requireExactProjectVisibilityFactory({ - loaders: { - getProject: () => - Promise.resolve( - ok({ - isDiscoverable: false, - isPublic: false - } as Project) - ) - } - })({ - projectVisibility: 'private', - projectId: cryptoRandomString({ length: 9 }) - }) - expect(result).toEqual(true) - }) - it('throws for unknown project visibility', async () => { - await expect( - requireExactProjectVisibilityFactory({ - loaders: { - getProject: () => - Promise.resolve( - ok({ - isDiscoverable: false, - isPublic: false - } as Project) - ) - } - })({ - // @ts-expect-error this is what im testing here - projectVisibility: 'unknown', - projectId: cryptoRandomString({ length: 9 }) - }) - ).rejects.toThrow(UncoveredError) - }) }) - describe('requireMinimumProjectRoleFactory return a function, that', () => { + describe('hasMinimumProjectRole returns a function, that', () => { + it('throws uncoveredError for unexpected loader errors', async () => { + await expect( + hasMinimumProjectRole({ + // @ts-expect-error deliberately testing an unexpeceted error type + getProjectRole: async () => err(ProjectNotFoundError) + })({ + projectId: cryptoRandomString({ length: 10 }), + userId: cryptoRandomString({ length: 10 }), + role: Roles.Stream.Contributor + }) + ).rejects.toThrowError(/Uncovered error/) + }) it('returns false, if there is no role for the user', async () => { - const result = await requireMinimumProjectRoleFactory({ - loaders: { - getProjectRole: () => Promise.resolve(err(ProjectRoleNotFoundError)) - } + const result = await hasMinimumProjectRole({ + getProjectRole: () => Promise.resolve(err(ProjectRoleNotFoundError)) })({ projectId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), @@ -107,10 +68,8 @@ describe('project checks', () => { expect(result).toEqual(false) }) it('returns false, if the role is not sufficient', async () => { - const result = await requireMinimumProjectRoleFactory({ - loaders: { - getProjectRole: () => Promise.resolve(ok(Roles.Stream.Reviewer)) - } + const result = await hasMinimumProjectRole({ + getProjectRole: () => Promise.resolve(ok(Roles.Stream.Reviewer)) })({ projectId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), @@ -119,10 +78,8 @@ describe('project checks', () => { expect(result).toEqual(false) }) it('returns true, if the role is sufficient', async () => { - const result = await requireMinimumProjectRoleFactory({ - loaders: { - getProjectRole: () => Promise.resolve(ok(Roles.Stream.Contributor)) - } + const result = await hasMinimumProjectRole({ + getProjectRole: () => Promise.resolve(ok(Roles.Stream.Contributor)) })({ projectId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), diff --git a/packages/shared/src/authz/checks/projects.ts b/packages/shared/src/authz/checks/projects.ts index 79bd65d1e..fa09c96e3 100644 --- a/packages/shared/src/authz/checks/projects.ts +++ b/packages/shared/src/authz/checks/projects.ts @@ -1,43 +1,40 @@ import { StreamRoles, throwUncoveredError } from '../../core/index.js' -import { ProjectNotFoundError } from '../domain/errors.js' -import { AuthCheckContext, AuthCheckContextLoaderKeys } from '../domain/loaders.js' -import { isMinimumProjectRole } from '../domain/projects/logic.js' -import { ProjectVisibility } from '../domain/projects/types.js' - -export const requireExactProjectVisibilityFactory = - ({ loaders }: AuthCheckContext) => - async (args: { - projectVisibility: ProjectVisibility - projectId: string - }): Promise => { - const { projectId, projectVisibility } = args - - const project = await loaders.getProject({ projectId }) - if (!project.isOk) throw new ProjectNotFoundError({ projectId }) - - switch (projectVisibility) { - case 'linkShareable': - return project.value.isDiscoverable === true - case 'public': - return project.value.isPublic === true - case 'private': - return project.value.isPublic !== true && project.value.isDiscoverable !== true - default: - throwUncoveredError(projectVisibility) - } - } - -export const requireMinimumProjectRoleFactory = - ({ loaders }: AuthCheckContext) => - async (args: { - userId: string - projectId: string - role: StreamRoles - }): Promise => { - const { userId, projectId, role: requiredProjectRole } = args +import { AuthPolicyCheck, ProjectContext, UserContext } from '../domain/policies.js' +import { isMinimumProjectRole } from '../domain/logic/roles.js' +export const hasMinimumProjectRole: AuthPolicyCheck< + 'getProjectRole', + UserContext & ProjectContext & { role: StreamRoles } +> = + (loaders) => + async ({ userId, projectId, role: requiredProjectRole }) => { const userProjectRole = await loaders.getProjectRole({ userId, projectId }) - return userProjectRole.isOk - ? isMinimumProjectRole(userProjectRole.value, requiredProjectRole) - : false + if (userProjectRole.isErr) { + switch (userProjectRole.error.code) { + case 'ProjectRoleNotFound': + return false + default: + throwUncoveredError(userProjectRole.error.code) + } + } + return isMinimumProjectRole(userProjectRole.value, requiredProjectRole) + } + +export const isPubliclyReadableProject: AuthPolicyCheck<'getProject', ProjectContext> = + (loaders) => + async ({ projectId }) => { + const project = await loaders.getProject({ projectId }) + if (project.isErr) { + switch (project.error.code) { + case 'ProjectNotFound': + return false + case 'ProjectNoAccess': + return false + case 'WorkspaceSsoSessionInvalid': + return false + default: + throwUncoveredError(project.error) + } + } + return project.value.isPublic || project.value.isDiscoverable } diff --git a/packages/shared/src/authz/checks/serverRole.spec.ts b/packages/shared/src/authz/checks/serverRole.spec.ts index db13883f3..75fa7ddbb 100644 --- a/packages/shared/src/authz/checks/serverRole.spec.ts +++ b/packages/shared/src/authz/checks/serverRole.spec.ts @@ -1,41 +1,80 @@ import { describe, expect, it } from 'vitest' -import { requireExactServerRole } from './serverRole.js' +import { hasMinimumServerRole, canUseAdminOverride } from './serverRole.js' import cryptoRandomString from 'crypto-random-string' import { err, ok } from 'true-myth/result' -import { ServerRoleNotFoundError } from '../domain/authErrors.js' +import { + ServerRoleNotFoundError, + ProjectRoleNotFoundError +} from '../domain/authErrors.js' +import { parseFeatureFlags } from '../../environment/index.js' -describe('requireExactServerRole returns a function, that', () => { - it('returns false for mismatch roles', async () => { - const result = await requireExactServerRole({ - loaders: { - getServerRole: () => Promise.resolve(ok('server:user')) - } +describe('hasMinimumServerRole returns a function, that', () => { + it('throws uncoveredError for unexpected loader errors', async () => { + await expect( + hasMinimumServerRole({ + // @ts-expect-error deliberately testing an unexpected loader error + getServerRole: async () => err(ProjectRoleNotFoundError) + })({ userId: cryptoRandomString({ length: 10 }), role: 'server:user' }) + ).rejects.toThrowError(/Uncovered error/) + }) + it.each([ServerRoleNotFoundError])( + 'turns expected loader error $code into false ', + async (loaderError) => { + const result = await hasMinimumServerRole({ + getServerRole: async () => err(loaderError) + })({ userId: cryptoRandomString({ length: 10 }), role: 'server:user' }) + expect(result).toEqual(false) + } + ) + it('returns false for smaller roles', async () => { + const result = await hasMinimumServerRole({ + getServerRole: async () => Promise.resolve(ok('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(err(ServerRoleNotFoundError)) - } + it('returns true for roles with enough power', async () => { + const result = await hasMinimumServerRole({ + getServerRole: () => Promise.resolve(ok('server:admin')) })({ 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(ok('server:admin')) - } - })({ - userId: cryptoRandomString({ length: 9 }), - role: 'server:admin' + role: 'server:guest' }) expect(result).toEqual(true) }) }) + +describe('canUseAdminOverride returns a function, that', () => { + it('returns false for admins if admin override is not enabled', async () => { + const result = await canUseAdminOverride({ + getEnv: async () => parseFeatureFlags({}), + getServerRole: async () => { + expect.fail() + } + })({ userId: cryptoRandomString({ length: 10 }) }) + expect(result).toEqual(false) + }) + it('returns false for non admins if admin override is not enabled', async () => { + const result = await canUseAdminOverride({ + getEnv: async () => parseFeatureFlags({}), + getServerRole: async () => ok('server:user') + })({ userId: cryptoRandomString({ length: 10 }) }) + expect(result).toEqual(false) + }) + it('returns false for non admins if admin override is enabled', async () => { + const result = await canUseAdminOverride({ + getEnv: async () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' }), + getServerRole: async () => ok('server:user') + })({ userId: cryptoRandomString({ length: 10 }) }) + expect(result).toEqual(false) + }) + it('returns true for admins if admin override is enabled', async () => { + const result = await canUseAdminOverride({ + getEnv: async () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' }), + getServerRole: async () => ok('server:admin') + })({ userId: cryptoRandomString({ length: 10 }) }) + expect(result).toEqual(true) + }) +}) diff --git a/packages/shared/src/authz/checks/serverRole.ts b/packages/shared/src/authz/checks/serverRole.ts index 11d8035a8..969ac07b5 100644 --- a/packages/shared/src/authz/checks/serverRole.ts +++ b/packages/shared/src/authz/checks/serverRole.ts @@ -1,13 +1,36 @@ -import { ServerRoles } from '../../core/constants.js' -import { AuthCheckContext, AuthCheckContextLoaderKeys } from '../domain/loaders.js' - -export const requireExactServerRole = - ({ loaders }: AuthCheckContext) => - async (args: { userId: string; role: ServerRoles }): Promise => { - const { userId, role: requiredServerRole } = args +import { Roles, ServerRoles } from '../../core/constants.js' +import { throwUncoveredError } from '../../core/index.js' +import { isMinimumServerRole } from '../domain/logic/roles.js' +import { AuthPolicyCheck } from '../domain/policies.js' +export const hasMinimumServerRole: AuthPolicyCheck< + 'getServerRole', + { userId: string; role: ServerRoles } +> = + (loaders) => + async ({ userId, role: requiredServerRole }) => { const userServerRole = await loaders.getServerRole({ userId }) - if (!userServerRole.isOk) return false - - return userServerRole.value === requiredServerRole + if (userServerRole.isErr) { + switch (userServerRole.error.code) { + case 'ServerRoleNotFound': + return false + default: + throwUncoveredError(userServerRole.error.code) + } + } + return isMinimumServerRole(userServerRole.value, requiredServerRole) + } + +export const canUseAdminOverride: AuthPolicyCheck< + 'getEnv' | 'getServerRole', + { userId: string } +> = + (loaders) => + async ({ userId }) => { + const { FF_ADMIN_OVERRIDE_ENABLED } = await loaders.getEnv() + if (!FF_ADMIN_OVERRIDE_ENABLED) return false + return await hasMinimumServerRole(loaders)({ + userId, + role: Roles.Server.Admin + }) } diff --git a/packages/shared/src/authz/checks/workspaceRole.spec.ts b/packages/shared/src/authz/checks/workspaceRole.spec.ts index 57028b7ce..3b68d9442 100644 --- a/packages/shared/src/authz/checks/workspaceRole.spec.ts +++ b/packages/shared/src/authz/checks/workspaceRole.spec.ts @@ -1,18 +1,39 @@ import { describe, expect, it } from 'vitest' -import { - requireAnyWorkspaceRole, - requireMinimumWorkspaceRole -} from './workspaceRole.js' +import { hasAnyWorkspaceRole, requireMinimumWorkspaceRole } from './workspaceRole.js' import cryptoRandomString from 'crypto-random-string' import { err, ok } from 'true-myth/result' -import { WorkspaceRoleNotFoundError } from '../domain/authErrors.js' +import { + WorkspaceRoleNotFoundError, + ProjectRoleNotFoundError +} from '../domain/authErrors.js' -describe('requireAnyWorkspaceRole returns a function, that', () => { +describe('hasAnyWorkspaceRole returns a function, that', () => { + it('throws uncoveredError for unexpected loader errors', async () => { + await expect( + hasAnyWorkspaceRole({ + // @ts-expect-error deliberately testing an unexpected loader error + getWorkspaceRole: async () => err(ProjectRoleNotFoundError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + }) + ).rejects.toThrowError(/Uncovered error/) + }) + it.each([WorkspaceRoleNotFoundError])( + 'turns expected loader error $code into false ', + async (loaderError) => { + const result = await hasAnyWorkspaceRole({ + getWorkspaceRole: async () => err(loaderError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + }) + expect(result).toEqual(false) + } + ) it('returns false if the user has no role', async () => { - const result = await requireAnyWorkspaceRole({ - loaders: { - getWorkspaceRole: () => Promise.resolve(err(WorkspaceRoleNotFoundError)) - } + const result = await hasAnyWorkspaceRole({ + getWorkspaceRole: async () => err(WorkspaceRoleNotFoundError) })({ userId: cryptoRandomString({ length: 9 }), workspaceId: cryptoRandomString({ length: 9 }) @@ -20,10 +41,8 @@ describe('requireAnyWorkspaceRole returns a function, that', () => { expect(result).toEqual(false) }) it('returns true if the user has a role', async () => { - const result = await requireAnyWorkspaceRole({ - loaders: { - getWorkspaceRole: () => Promise.resolve(ok('workspace:member')) - } + const result = await hasAnyWorkspaceRole({ + getWorkspaceRole: async () => ok('workspace:member') })({ userId: cryptoRandomString({ length: 9 }), workspaceId: cryptoRandomString({ length: 9 }) @@ -33,50 +52,57 @@ describe('requireAnyWorkspaceRole returns a function, that', () => { }) 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(err(WorkspaceRoleNotFoundError)) - } - })({ - userId: cryptoRandomString({ length: 9 }), - workspaceId: cryptoRandomString({ length: 9 }), - role: 'workspace:member' - }) - expect(result).toEqual(false) + it('throws uncoveredError for unexpected loader errors', async () => { + await expect( + requireMinimumWorkspaceRole({ + // @ts-expect-error deliberately testing an unexpected loader error + getWorkspaceRole: async () => err(ProjectRoleNotFoundError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }), + role: 'workspace:member' + }) + ).rejects.toThrowError(/Uncovered error/) }) + it.each([WorkspaceRoleNotFoundError])( + 'turns expected loader error $code into false ', + async (loaderError) => { + const result = await requireMinimumWorkspaceRole({ + getWorkspaceRole: async () => err(loaderError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }), + 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(ok('workspace:member')) - } + getWorkspaceRole: () => Promise.resolve(ok('workspace:member')) })({ - userId: cryptoRandomString({ length: 9 }), - workspaceId: cryptoRandomString({ length: 9 }), + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }), role: 'workspace:admin' }) expect(result).toEqual(false) }) it('returns true if user matches target role', async () => { const result = await requireMinimumWorkspaceRole({ - loaders: { - getWorkspaceRole: () => Promise.resolve(ok('workspace:member')) - } + getWorkspaceRole: () => Promise.resolve(ok('workspace:member')) })({ - userId: cryptoRandomString({ length: 9 }), - workspaceId: cryptoRandomString({ length: 9 }), + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }), role: 'workspace:member' }) expect(result).toEqual(true) }) it('returns true if user exceeds target role', async () => { const result = await requireMinimumWorkspaceRole({ - loaders: { - getWorkspaceRole: () => Promise.resolve(ok('workspace:admin')) - } + getWorkspaceRole: () => Promise.resolve(ok('workspace:admin')) })({ - userId: cryptoRandomString({ length: 9 }), - workspaceId: cryptoRandomString({ length: 9 }), + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }), role: 'workspace:member' }) expect(result).toEqual(true) diff --git a/packages/shared/src/authz/checks/workspaceRole.ts b/packages/shared/src/authz/checks/workspaceRole.ts index 8014f3fa3..fa494e250 100644 --- a/packages/shared/src/authz/checks/workspaceRole.ts +++ b/packages/shared/src/authz/checks/workspaceRole.ts @@ -1,28 +1,39 @@ import { WorkspaceRoles } from '../../core/constants.js' -import { AuthCheckContext, AuthCheckContextLoaderKeys } from '../domain/loaders.js' -import { isMinimumWorkspaceRole } from '../domain/workspaces/logic.js' - -export const requireAnyWorkspaceRole = - ({ loaders }: AuthCheckContext) => - async (args: { userId: string; workspaceId: string }): Promise => { - const { userId, workspaceId } = args +import { throwUncoveredError } from '../../core/index.js' +import { isMinimumWorkspaceRole } from '../domain/logic/roles.js' +import { AuthPolicyCheck } from '../domain/policies.js' +export const requireMinimumWorkspaceRole: AuthPolicyCheck< + 'getWorkspaceRole', + { userId: string; workspaceId: string; role: WorkspaceRoles } +> = + (loaders) => + async ({ userId, workspaceId, role: requiredWorkspaceRole }) => { const userWorkspaceRole = await loaders.getWorkspaceRole({ userId, workspaceId }) - return userWorkspaceRole.isOk + if (userWorkspaceRole.isErr) { + switch (userWorkspaceRole.error.code) { + case 'WorkspaceRoleNotFound': + return false + default: + throwUncoveredError(userWorkspaceRole.error.code) + } + } + + return isMinimumWorkspaceRole(userWorkspaceRole.value, requiredWorkspaceRole) } -export const requireMinimumWorkspaceRole = - ({ loaders }: AuthCheckContext) => - async (args: { - userId: string - workspaceId: string - role: WorkspaceRoles - }): Promise => { - const { userId, workspaceId, role: requiredWorkspaceRole } = args - +export const hasAnyWorkspaceRole: AuthPolicyCheck< + 'getWorkspaceRole', + { userId: string; workspaceId: string } +> = + (loaders) => + async ({ userId, workspaceId }) => { const userWorkspaceRole = await loaders.getWorkspaceRole({ userId, workspaceId }) - - return userWorkspaceRole.isOk - ? isMinimumWorkspaceRole(userWorkspaceRole.value, requiredWorkspaceRole) - : false + if (userWorkspaceRole.isOk) return true + switch (userWorkspaceRole.error.code) { + case 'WorkspaceRoleNotFound': + return false + default: + throwUncoveredError(userWorkspaceRole.error.code) + } } diff --git a/packages/shared/src/authz/checks/workspaceSso.spec.ts b/packages/shared/src/authz/checks/workspaceSso.spec.ts deleted file mode 100644 index e65230d2e..000000000 --- a/packages/shared/src/authz/checks/workspaceSso.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { requireValidWorkspaceSsoSession } from './workspaceSso.js' -import cryptoRandomString from 'crypto-random-string' -import { err, ok } from 'true-myth/result' -import { WorkspaceSsoSessionNotFoundError } from '../domain/authErrors.js' - -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(err(WorkspaceSsoSessionNotFoundError)) - } - })({ - 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( - ok({ - 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( - ok({ - userId, - providerId, - validUntil - }) - ) - } - })({ - userId, - workspaceId - }) - expect(result).toBe(true) - }) -}) diff --git a/packages/shared/src/authz/checks/workspaceSso.ts b/packages/shared/src/authz/checks/workspaceSso.ts deleted file mode 100644 index d3488dbfe..000000000 --- a/packages/shared/src/authz/checks/workspaceSso.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { AuthCheckContext, AuthCheckContextLoaderKeys } from '../domain/loaders.js' - -export const requireValidWorkspaceSsoSession = - ({ - loaders - }: AuthCheckContext) => - async (args: { userId: string; workspaceId: string }): Promise => { - const { userId, workspaceId } = args - - const workspaceSsoSession = await loaders.getWorkspaceSsoSession({ - userId, - workspaceId - }) - if (!workspaceSsoSession.isOk) return false - - const isExpiredSession = - new Date().getTime() > workspaceSsoSession.value.validUntil.getTime() - - return !isExpiredSession - } diff --git a/packages/shared/src/authz/domain/authErrors.ts b/packages/shared/src/authz/domain/authErrors.ts index 24fa03341..6e6a351fb 100644 --- a/packages/shared/src/authz/domain/authErrors.ts +++ b/packages/shared/src/authz/domain/authErrors.ts @@ -60,6 +60,16 @@ export const WorkspaceRoleNotFoundError = defineAuthError({ message: 'The user does not have a role in the workspace' }) +export const ServerNoAccessError = defineAuthError({ + code: 'ServerNoAccess', + message: 'You do not have access to this server' +}) + +export const ServerNoSessionError = defineAuthError({ + code: 'ServerNoSession', + message: 'You are not logged in to this server' +}) + export const ServerRoleNotFoundError = defineAuthError({ code: 'ServerRoleNotFound', message: 'Could not resolve your server role' diff --git a/packages/shared/src/authz/domain/errors.ts b/packages/shared/src/authz/domain/errors.ts index 4c0501b8e..3ac9bed30 100644 --- a/packages/shared/src/authz/domain/errors.ts +++ b/packages/shared/src/authz/domain/errors.ts @@ -1,15 +1,3 @@ -export class LogicError extends Error { - constructor(message: string) { - super(message) - } -} - -export class ProjectNotFoundError extends Error { - constructor({ projectId }: { projectId: string }) { - super(`Project with id ${projectId} not found`) - } -} - export class InvalidRoleError extends Error { constructor(message: string) { super(message) diff --git a/packages/shared/src/authz/domain/loaders.ts b/packages/shared/src/authz/domain/loaders.ts index d689ec5ee..7ee96371d 100644 --- a/packages/shared/src/authz/domain/loaders.ts +++ b/packages/shared/src/authz/domain/loaders.ts @@ -36,6 +36,8 @@ type AuthContextLoaderMappingDefinition< /** * All loaders must be listed here for app startup validation to work properly */ + +/* v8 ignore start */ export const AuthCheckContextLoaderKeys = { getEnv: 'getEnv', getProject: 'getProject', @@ -46,6 +48,7 @@ export const AuthCheckContextLoaderKeys = { getWorkspaceSsoProvider: 'getWorkspaceSsoProvider', getWorkspaceSsoSession: 'getWorkspaceSsoSession' } +/* v8 ignore end */ export type AuthCheckContextLoaderKeys = (typeof AuthCheckContextLoaderKeys)[keyof typeof AuthCheckContextLoaderKeys] diff --git a/packages/shared/src/authz/domain/logic/roles.spec.ts b/packages/shared/src/authz/domain/logic/roles.spec.ts new file mode 100644 index 000000000..21c35adb9 --- /dev/null +++ b/packages/shared/src/authz/domain/logic/roles.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + isMinimumProjectRole, + isMinimumWorkspaceRole, + isMinimumServerRole +} from './roles.js' +import { InvalidRoleError } from '../errors.js' + +describe('authz domain roles', () => { + 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) + }) + it('throws an error if the target role is invalid', () => { + //@ts-expect-error im testing invalid target role here + expect(() => isMinimumProjectRole('stream:owner', 'invalid')).toThrow( + InvalidRoleError + ) + }) + it('throws an error if the role is invalid', () => { + //@ts-expect-error im testing invalid role here + expect(() => isMinimumProjectRole('invalid', 'stream:contributor')).toThrow( + InvalidRoleError + ) + }) + }) + describe('isMinimumWorkspaceRole', () => { + 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) + }) + it('throws an error if target role is invalid', () => { + expect(() => + // @ts-expect-error im testing an invalid target role + isMinimumWorkspaceRole('workspace:admin', 'workspace:invalid') + ).toThrow(InvalidRoleError) + }) + it('throws an error if role is invalid', () => { + expect(() => + // @ts-expect-error im testing an invalid role + isMinimumWorkspaceRole('workspace:invalid', 'workspace:admin') + ).toThrow(InvalidRoleError) + }) + }) + describe('isMinimumServerRole', () => { + it('returns true if role has bigger weight than target role', () => { + expect(isMinimumServerRole('server:admin', 'server:user')).toBe(true) + }) + it('returns true if role has the same weight as the target role', () => { + expect(isMinimumServerRole('server:admin', 'server:admin')).toBe(true) + }) + it('returns false if role has smaller weight than target role', () => { + expect(isMinimumServerRole('server:guest', 'server:admin')).toBe(false) + }) + it('throws an error if target role is invalid', () => { + expect(() => + // @ts-expect-error im testing an invalid target role + isMinimumServerRole('server:admin', 'server:invalid') + ).toThrow(InvalidRoleError) + }) + it('throws an error if role is invalid', () => { + expect(() => + // @ts-expect-error im testing an invalid role + isMinimumServerRole('server:invalid', 'server:admin') + ).toThrow(InvalidRoleError) + }) + }) +}) diff --git a/packages/shared/src/authz/domain/logic/roles.ts b/packages/shared/src/authz/domain/logic/roles.ts new file mode 100644 index 000000000..4b9792824 --- /dev/null +++ b/packages/shared/src/authz/domain/logic/roles.ts @@ -0,0 +1,53 @@ +import { + StreamRoles, + ServerRoles, + RoleInfo, + WorkspaceRoles +} from '../../../core/constants.js' +import { InvalidRoleError } from '../errors.js' + +export const isMinimumProjectRole = ( + role: StreamRoles, + targetRole: StreamRoles +): boolean => { + if (!(role in RoleInfo.Stream)) { + throw new InvalidRoleError(`Invalid role ${role}`) + } + if (!(targetRole in RoleInfo.Stream)) { + throw new InvalidRoleError(`Invalid target role ${targetRole}`) + } + const roleWeight = RoleInfo.Stream[role].weight + const targetRoleWeight = RoleInfo.Stream[targetRole].weight + return roleWeight >= targetRoleWeight +} + +export const isMinimumServerRole = ( + role: ServerRoles, + targetRole: ServerRoles +): boolean => { + if (!(role in RoleInfo.Server)) { + throw new InvalidRoleError(`Invalid role ${role}`) + } + if (!(targetRole in RoleInfo.Server)) { + throw new InvalidRoleError(`Invalid target role ${targetRole}`) + } + const roleWeight = RoleInfo.Server[role].weight + const targetRoleWeight = RoleInfo.Server[targetRole].weight + return roleWeight >= targetRoleWeight +} + +export const isMinimumWorkspaceRole = ( + role: WorkspaceRoles, + targetRole: WorkspaceRoles +): boolean => { + if (!(role in RoleInfo.Workspace)) { + throw new InvalidRoleError(`Invalid role ${role}`) + } + if (!(targetRole in RoleInfo.Workspace)) { + throw new InvalidRoleError(`Invalid target role ${targetRole}`) + } + const roleWeight = RoleInfo.Workspace[role].weight + const targetRoleWeight = RoleInfo.Workspace[targetRole].weight + + return roleWeight >= targetRoleWeight +} diff --git a/packages/shared/src/authz/domain/policies.ts b/packages/shared/src/authz/domain/policies.ts index 9633f6d9e..155f6a629 100644 --- a/packages/shared/src/authz/domain/policies.ts +++ b/packages/shared/src/authz/domain/policies.ts @@ -1,15 +1,34 @@ import Result from 'true-myth/result' +import Unit from 'true-myth/unit' import { AuthError } from './authErrors.js' import { AuthCheckContextLoaderKeys, AuthCheckContextLoaders } from './loaders.js' +import Maybe from 'true-myth/maybe' export type ProjectContext = { projectId: string } +export type UserContext = { userId: string } +export type MaybeUserContext = { userId?: string } -export type UserContext = { userId?: string } - -export type AuthPolicyFactory< +// a complete policy always returns a full result +export type AuthPolicy< LoaderKeys extends AuthCheckContextLoaderKeys, Args extends object, ExpectedAuthErrors extends AuthError > = ( loaders: AuthCheckContextLoaders -) => (args: Args) => Promise> +) => (args: Args) => Promise> + +// a policy fragment is a partial policy, where it can potentially make a decision +// but maybe it cannot +export type AuthPolicyFragment< + LoaderKeys extends AuthCheckContextLoaderKeys, + Args extends object, + ExpectedAuthErrors extends AuthError +> = ( + loaders: AuthCheckContextLoaders +) => (args: Args) => Promise>> +// ) => (args: Args) => Promise>> + +export type AuthPolicyCheck< + LoaderKeys extends AuthCheckContextLoaderKeys, + Args extends object +> = (loaders: AuthCheckContextLoaders) => (args: Args) => Promise diff --git a/packages/shared/src/authz/domain/projects/logic.spec.ts b/packages/shared/src/authz/domain/projects/logic.spec.ts deleted file mode 100644 index 43c56a214..000000000 --- a/packages/shared/src/authz/domain/projects/logic.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isMinimumProjectRole } from './logic.js' -import { InvalidRoleError } from '../errors.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) - }) - it('throws an error if the target role is invalid', () => { - //@ts-expect-error im testing invalid target role here - expect(() => isMinimumProjectRole('stream:owner', 'invalid')).toThrow( - InvalidRoleError - ) - }) - it('throws an error if the role is invalid', () => { - //@ts-expect-error im testing invalid role here - expect(() => isMinimumProjectRole('invalid', 'stream:contributor')).toThrow( - InvalidRoleError - ) - }) - }) -}) diff --git a/packages/shared/src/authz/domain/projects/logic.ts b/packages/shared/src/authz/domain/projects/logic.ts deleted file mode 100644 index 43775e7af..000000000 --- a/packages/shared/src/authz/domain/projects/logic.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { StreamRoles, RoleInfo } from '../../../core/constants.js' -import { InvalidRoleError } from '../errors.js' - -export const isMinimumProjectRole = ( - role: StreamRoles, - targetRole: StreamRoles -): boolean => { - if (!(role in RoleInfo.Stream)) { - throw new InvalidRoleError(`Invalid role ${role}`) - } - if (!(targetRole in RoleInfo.Stream)) { - throw new InvalidRoleError(`Invalid target role ${targetRole}`) - } - const roleWeight = RoleInfo.Stream[role].weight - const targetRoleWeight = RoleInfo.Stream[targetRole].weight - return roleWeight >= targetRoleWeight -} diff --git a/packages/shared/src/authz/domain/projects/operations.ts b/packages/shared/src/authz/domain/projects/operations.ts index f488c8a89..6cd9436df 100644 --- a/packages/shared/src/authz/domain/projects/operations.ts +++ b/packages/shared/src/authz/domain/projects/operations.ts @@ -4,13 +4,19 @@ import { Project } from './types.js' import { ProjectNoAccessError, ProjectNotFoundError, - ProjectRoleNotFoundError + ProjectRoleNotFoundError, + WorkspaceSsoSessionInvalidError } from '../authErrors.js' export type GetProject = (args: { projectId: string }) => Promise< - Result + Result< + Project, + | typeof ProjectNotFoundError + | typeof ProjectNoAccessError + | typeof WorkspaceSsoSessionInvalidError + > > export type GetProjectRole = (args: { diff --git a/packages/shared/src/authz/domain/projects/types.ts b/packages/shared/src/authz/domain/projects/types.ts index 728eb1072..79f33e81f 100644 --- a/packages/shared/src/authz/domain/projects/types.ts +++ b/packages/shared/src/authz/domain/projects/types.ts @@ -1,5 +1,4 @@ export type Project = { - // TODO: Deprecated field? isDiscoverable: boolean isPublic: boolean workspaceId: string | null diff --git a/packages/shared/src/authz/domain/workspaces/logic.spec.ts b/packages/shared/src/authz/domain/workspaces/logic.spec.ts deleted file mode 100644 index 0a66acc3c..000000000 --- a/packages/shared/src/authz/domain/workspaces/logic.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isMinimumWorkspaceRole } from './logic.js' -import { InvalidRoleError } from '../errors.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) - }) - it('throws an error if target role is invalid', () => { - expect(() => - // @ts-expect-error im testing an invalid target role - isMinimumWorkspaceRole('workspace:admin', 'workspace:invalid') - ).toThrow(InvalidRoleError) - }) - it('throws an error if role is invalid', () => { - expect(() => - // @ts-expect-error im testing an invalid role - isMinimumWorkspaceRole('workspace:invalid', 'workspace:admin') - ).toThrow(InvalidRoleError) - }) - }) -}) diff --git a/packages/shared/src/authz/domain/workspaces/logic.ts b/packages/shared/src/authz/domain/workspaces/logic.ts deleted file mode 100644 index 74b05a809..000000000 --- a/packages/shared/src/authz/domain/workspaces/logic.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { RoleInfo, WorkspaceRoles } from '../../../core/constants.js' -import { InvalidRoleError } from '../errors.js' - -export const isMinimumWorkspaceRole = ( - role: WorkspaceRoles, - targetRole: WorkspaceRoles -): boolean => { - if (!(role in RoleInfo.Workspace)) { - throw new InvalidRoleError(`Invalid role ${role}`) - } - if (!(targetRole in RoleInfo.Workspace)) { - throw new InvalidRoleError(`Invalid target role ${targetRole}`) - } - const roleWeight = RoleInfo.Workspace[role].weight - const targetRoleWeight = RoleInfo.Workspace[targetRole].weight - - return roleWeight >= targetRoleWeight -} diff --git a/packages/shared/src/authz/domain/workspaces/operations.ts b/packages/shared/src/authz/domain/workspaces/operations.ts index 355747fd4..58a774159 100644 --- a/packages/shared/src/authz/domain/workspaces/operations.ts +++ b/packages/shared/src/authz/domain/workspaces/operations.ts @@ -27,4 +27,4 @@ export type GetWorkspaceSsoSession = (args: { workspaceId: string }) => Promise> -export type GetEnv = () => Result +export type GetEnv = () => Promise diff --git a/packages/shared/src/authz/fragments/workspaceSso.spec.ts b/packages/shared/src/authz/fragments/workspaceSso.spec.ts new file mode 100644 index 000000000..06a261825 --- /dev/null +++ b/packages/shared/src/authz/fragments/workspaceSso.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { maybeMemberRoleWithValidSsoSessionIfNeeded } from './workspaceSso.js' +import cryptoRandomString from 'crypto-random-string' +import { err, ok } from 'true-myth/result' +import { + ProjectNotFoundError, + WorkspaceRoleNotFoundError, + WorkspaceSsoProviderNotFoundError, + WorkspaceSsoSessionInvalidError, + WorkspaceSsoSessionNotFoundError +} from '../domain/authErrors.js' +import { just, nothing } from 'true-myth/maybe' + +describe('maybeMemberRoleWithValidSsoSessionIfNeeded returns a function, that', () => { + it('returns nothing if user does not have a workspace role', async () => { + const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({ + getWorkspaceRole: async () => err(WorkspaceRoleNotFoundError), + getWorkspaceSsoProvider: async () => err(WorkspaceSsoProviderNotFoundError), + getWorkspaceSsoSession: async () => err(WorkspaceSsoSessionNotFoundError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + }) + expect(result).toStrictEqual(nothing()) + }) + it('returns nothing if user does not have a minimum workspace:member role', async () => { + const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({ + getWorkspaceRole: async () => ok('workspace:guest'), + getWorkspaceSsoProvider: async () => err(WorkspaceSsoProviderNotFoundError), + getWorkspaceSsoSession: async () => err(WorkspaceSsoSessionNotFoundError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + }) + expect(result).toStrictEqual(nothing()) + }) + it('returns just(ok()) if user is a member and workspace has no SSO provider', async () => { + const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({ + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoProvider: async () => err(WorkspaceSsoProviderNotFoundError), + getWorkspaceSsoSession: async () => err(WorkspaceSsoSessionNotFoundError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + }) + expect(result).toStrictEqual(just(ok())) + }) + it('throws uncovered error for unexpected ssoProvider loader errors', async () => { + const result = maybeMemberRoleWithValidSsoSessionIfNeeded({ + getWorkspaceRole: async () => ok('workspace:member'), + // @ts-expect-error testing uncovered errors + getWorkspaceSsoProvider: async () => err(ProjectNotFoundError), + getWorkspaceSsoSession: async () => err(WorkspaceSsoSessionNotFoundError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + }) + await expect(result).rejects.toThrowError(/Uncovered error/) + }) + it('throws uncovered error for unexpected ssoSession loader errors', async () => { + const result = maybeMemberRoleWithValidSsoSessionIfNeeded({ + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoProvider: async () => + ok({ providerId: cryptoRandomString({ length: 10 }) }), + // @ts-expect-error testing uncovered errors + getWorkspaceSsoSession: async () => err(ProjectNotFoundError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + }) + await expect(result).rejects.toThrowError(/Uncovered error/) + }) + it('returns WorkspaceSsoSessionInvalidError if user does not have an SSO session', async () => { + const result = maybeMemberRoleWithValidSsoSessionIfNeeded({ + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoProvider: async () => + ok({ providerId: cryptoRandomString({ length: 10 }) }), + getWorkspaceSsoSession: async () => err(WorkspaceSsoSessionNotFoundError) + })({ + userId: cryptoRandomString({ length: 10 }), + workspaceId: cryptoRandomString({ length: 10 }) + }) + await expect(result).resolves.toStrictEqual( + just(err(WorkspaceSsoSessionInvalidError)) + ) + }) + it('returns WorkspaceSsoSessionInvalidError if user has an expired sso session', async () => { + const userId = cryptoRandomString({ length: 10 }) + const providerId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + + const validUntil = new Date() + validUntil.setDate(validUntil.getDate() - 1) + + const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({ + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoProvider: async () => + ok({ providerId: cryptoRandomString({ length: 10 }) }), + getWorkspaceSsoSession: async () => ok({ providerId, validUntil, userId }) + })({ + userId, + workspaceId + }) + expect(result).toStrictEqual(just(err(WorkspaceSsoSessionInvalidError))) + }) + it('returns true if user has a valid sso session', async () => { + const userId = cryptoRandomString({ length: 10 }) + const providerId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + + const validUntil = new Date() + validUntil.setDate(validUntil.getDate() + 100) + + const result = await maybeMemberRoleWithValidSsoSessionIfNeeded({ + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoProvider: async () => + ok({ providerId: cryptoRandomString({ length: 10 }) }), + getWorkspaceSsoSession: async () => ok({ providerId, validUntil, userId }) + })({ + userId, + workspaceId + }) + expect(result).toStrictEqual(just(ok())) + }) +}) diff --git a/packages/shared/src/authz/fragments/workspaceSso.ts b/packages/shared/src/authz/fragments/workspaceSso.ts new file mode 100644 index 000000000..11a415dee --- /dev/null +++ b/packages/shared/src/authz/fragments/workspaceSso.ts @@ -0,0 +1,54 @@ +import { err, isErr, ok } from 'true-myth/result' +import { throwUncoveredError } from '../../core/helpers/error.js' +import { WorkspaceSsoSessionInvalidError } from '../domain/authErrors.js' +import { AuthPolicyFragment } from '../domain/policies.js' +import { requireMinimumWorkspaceRole } from '../checks/workspaceRole.js' +import { just, nothing } from 'true-myth/maybe' + +export const maybeMemberRoleWithValidSsoSessionIfNeeded: AuthPolicyFragment< + 'getWorkspaceRole' | 'getWorkspaceSsoProvider' | 'getWorkspaceSsoSession', + { userId: string; workspaceId: string }, + typeof WorkspaceSsoSessionInvalidError +> = + (loaders) => + async ({ userId, workspaceId }) => { + const hasMinimumMemberRole = await requireMinimumWorkspaceRole(loaders)({ + userId, + workspaceId, + role: 'workspace:member' + }) + + if (!hasMinimumMemberRole) return nothing() + + const workspaceSsoProvider = await loaders.getWorkspaceSsoProvider({ + workspaceId + }) + if (isErr(workspaceSsoProvider)) { + switch (workspaceSsoProvider.error.code) { + case 'WorkspaceSsoProviderNotFound': + // if there is no SSO provider, we can early return here + return just(ok()) + default: + throwUncoveredError(workspaceSsoProvider.error.code) + } + } + + const workspaceSsoSession = await loaders.getWorkspaceSsoSession({ + userId, + workspaceId + }) + if (isErr(workspaceSsoSession)) { + switch (workspaceSsoSession.error.code) { + case 'WorkspaceSsoSessionNotFound': + return just(err(WorkspaceSsoSessionInvalidError)) + default: + throwUncoveredError(workspaceSsoSession.error.code) + } + } + + const isExpiredSession = + new Date().getTime() > workspaceSsoSession.value.validUntil.getTime() + + if (isExpiredSession) return just(err(WorkspaceSsoSessionInvalidError)) + return just(ok()) + } diff --git a/packages/shared/src/authz/policies/canQueryProject.spec.ts b/packages/shared/src/authz/policies/canQueryProject.spec.ts index f81a7146e..b136fbb64 100644 --- a/packages/shared/src/authz/policies/canQueryProject.spec.ts +++ b/packages/shared/src/authz/policies/canQueryProject.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, assert } from 'vitest' -import { canQueryProjectPolicyFactory } from './canQueryProject.js' +import { canQueryProjectPolicy } from './canQueryProject.js' import { parseFeatureFlags } from '../../environment/index.js' import crs from 'crypto-random-string' import { Roles } from '../../core/constants.js' @@ -7,12 +7,18 @@ import { ProjectNoAccessError, ProjectNotFoundError, ProjectRoleNotFoundError, + ServerNoAccessError, + ServerNoSessionError, + ServerRoleNotFoundError, + WorkspaceNoAccessError, WorkspaceRoleNotFoundError, WorkspaceSsoProviderNotFoundError, + WorkspaceSsoSessionInvalidError, WorkspaceSsoSessionNotFoundError } from '../domain/authErrors.js' import { getProjectFake } from '../../tests/fakes.js' import { err, ok } from 'true-myth/result' +import cryptoRandomString from 'crypto-random-string' const canQueryProjectArgs = () => { const projectId = crs({ length: 10 }) @@ -20,12 +26,16 @@ const canQueryProjectArgs = () => { 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: async () => ok(parseFeatureFlags({})), - getProject: () => Promise.resolve(err(ProjectNotFoundError)), +describe('canQueryProjectPolicy creates a function, that handles ', () => { + describe('project loader errors', () => { + it.each([ + ProjectNoAccessError, + ProjectNotFoundError, + WorkspaceSsoSessionInvalidError + ])('expected $code error by returning the error', async (expectedError) => { + const result = await canQueryProjectPolicy({ + getEnv: async () => parseFeatureFlags({}), + getProject: async () => err(expectedError), getProjectRole: () => { assert.fail() }, @@ -41,19 +51,39 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () => getWorkspaceSsoProvider: () => { assert.fail() } - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) + })(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(false) - if (!canQuery.isOk) { - expect(canQuery.error.code).toBe(ProjectNotFoundError.code) - } + expect(result).toStrictEqual(err(expectedError)) + }) + it('unexpected error by throwing UncoveredError', async () => { + const result = canQueryProjectPolicy({ + getEnv: async () => parseFeatureFlags({}), + // @ts-expect-error testing uncovered error handling + getProject: async () => err(ProjectRoleNotFoundError), + getProjectRole: () => { + assert.fail() + }, + getServerRole: () => { + assert.fail() + }, + getWorkspaceRole: () => { + assert.fail() + }, + getWorkspaceSsoSession: () => { + assert.fail() + }, + getWorkspaceSsoProvider: () => { + assert.fail() + } + })(canQueryProjectArgs()) + + await expect(result).rejects.toThrowError(/Uncovered error/) }) }) describe('project visibility', () => { it('allows anyone on a public project', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ - getEnv: async () => ok(parseFeatureFlags({})), + const canQueryProject = canQueryProjectPolicy({ + getEnv: async () => parseFeatureFlags({}), getProject: getProjectFake({ isPublic: true }), getProjectRole: () => { assert.fail() @@ -75,8 +105,8 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () => expect(canQuery.isOk).toBe(true) }) it('allows anyone on a linkShareable project', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ - getEnv: async () => ok(parseFeatureFlags({})), + const canQueryProject = canQueryProjectPolicy({ + getEnv: async () => parseFeatureFlags({}), getProject: getProjectFake({ isDiscoverable: true }), getProjectRole: () => { assert.fail() @@ -99,22 +129,76 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () => }) }) + describe('server roles', () => { + it('allows access for archived server users with a project role on a public project', async () => { + const result = canQueryProjectPolicy({ + getEnv: async () => + parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }), + getProject: getProjectFake({ isDiscoverable: false, isPublic: true }), + getProjectRole: async () => ok(Roles.Stream.Owner), + getServerRole: async () => ok(Roles.Server.ArchivedUser), + getWorkspaceRole: () => { + assert.fail() + }, + getWorkspaceSsoSession: () => { + assert.fail() + }, + getWorkspaceSsoProvider: () => { + assert.fail() + } + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(ok()) + }) + it('does not allow access for archived server users with a project role', async () => { + const result = canQueryProjectPolicy({ + getEnv: async () => + parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }), + getProject: getProjectFake({ isDiscoverable: false, isPublic: false }), + getProjectRole: async () => ok(Roles.Stream.Owner), + getServerRole: async () => ok(Roles.Server.ArchivedUser), + getWorkspaceRole: () => { + assert.fail() + }, + getWorkspaceSsoSession: () => { + assert.fail() + }, + getWorkspaceSsoProvider: () => { + assert.fail() + } + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(err(ServerNoAccessError)) + }) + it('does not allow access for non public projects for unknown users', async () => { + const result = canQueryProjectPolicy({ + getEnv: async () => + parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }), + getProject: getProjectFake({ isDiscoverable: false, isPublic: false }), + getProjectRole: async () => ok(Roles.Stream.Owner), + getServerRole: async () => err(ServerRoleNotFoundError), + getWorkspaceRole: () => { + assert.fail() + }, + getWorkspaceSsoSession: () => { + assert.fail() + }, + getWorkspaceSsoProvider: () => { + assert.fail() + } + })({ userId: undefined, projectId: cryptoRandomString({ length: 10 }) }) + await expect(result).resolves.toStrictEqual(err(ServerNoSessionError)) + }) + }) + describe('project roles', () => { it.each(Object.values(Roles.Stream))( - 'allows access to private projects with role %', + 'allows access for active server users to private projects with %s role', async (role) => { - const canQueryProject = canQueryProjectPolicyFactory({ + const canQueryProject = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'false' - }) - ), + parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false }), - getProjectRole: () => Promise.resolve(ok(role)), - getServerRole: () => { - assert.fail() - }, + getProjectRole: async () => ok(role), + getServerRole: async () => ok('server:user'), getWorkspaceRole: () => { assert.fail() }, @@ -130,18 +214,12 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () => } ) it('does not allow access to private projects without a project role', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'false' - }) - ), + parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false }), - getProjectRole: () => Promise.resolve(err(ProjectRoleNotFoundError)), - getServerRole: () => { - assert.fail() - }, + getProjectRole: async () => err(ProjectRoleNotFoundError), + getServerRole: async () => ok(Roles.Server.Admin), getWorkspaceRole: () => { assert.fail() }, @@ -151,21 +229,16 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () => getWorkspaceSsoProvider: () => { assert.fail() } - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(false) - if (!canQuery.isOk) { - expect(canQuery.error.code).toBe(ProjectNoAccessError.code) - } + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(err(ProjectNoAccessError)) }) }) describe('admin override', () => { it('allows server admins without project roles on private projects if admin override is enabled', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ - getEnv: async () => - ok(parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' })), + const result = canQueryProjectPolicy({ + getEnv: async () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false }), - getServerRole: () => Promise.resolve(ok(Roles.Server.Admin)), + getServerRole: async () => ok(Roles.Server.Admin), getProjectRole: () => { assert.fail() }, @@ -178,25 +251,20 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () => getWorkspaceSsoProvider: () => { assert.fail() } - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(true) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(ok()) }) it('does not allow server admins without project roles on private projects if admin override is disabled', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_ADMIN_OVERRIDE_ENABLED: 'false', - FF_WORKSPACES_MODULE_ENABLED: 'false' - }) - ), + parseFeatureFlags({ + FF_ADMIN_OVERRIDE_ENABLED: 'false', + FF_WORKSPACES_MODULE_ENABLED: 'false' + }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false }), - getServerRole: () => Promise.resolve(ok(Roles.Server.Admin)), - getProjectRole: () => { - return Promise.resolve(err(ProjectRoleNotFoundError)) - }, + getServerRole: async () => ok(Roles.Server.Admin), + getProjectRole: async () => err(ProjectRoleNotFoundError), getWorkspaceRole: () => { assert.fail() }, @@ -206,28 +274,22 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () => getWorkspaceSsoProvider: () => { assert.fail() } - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(false) - if (!canQuery.isOk) { - expect(canQuery.error.code).toBe(ProjectNoAccessError.code) - } + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(err(ProjectNoAccessError)) }) }) describe('the workspace world', () => { it('does not check workspace rules if the workspaces module is not enabled', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok(parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' })), + parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false, workspaceId: crs({ length: 10 }) }), - getProjectRole: () => Promise.resolve(ok('stream:contributor')), - getServerRole: () => { - assert.fail() - }, + getProjectRole: async () => ok('stream:contributor'), + getServerRole: async () => ok('server:user'), getWorkspaceRole: () => { assert.fail() }, @@ -237,200 +299,184 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () => getWorkspaceSsoProvider: () => { assert.fail() } - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(true) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(ok()) }) it('does not allow project access without a workspace role', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'true' - }) - ), + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false, workspaceId: crs({ length: 10 }) }), - getProjectRole: () => Promise.resolve(ok('stream:contributor')), - getServerRole: () => { - assert.fail() - }, - getWorkspaceRole: () => Promise.resolve(err(WorkspaceRoleNotFoundError)), + getProjectRole: async () => ok('stream:contributor'), + getServerRole: async () => ok('server:user'), + getWorkspaceRole: async () => err(WorkspaceRoleNotFoundError), getWorkspaceSsoSession: () => { assert.fail() }, getWorkspaceSsoProvider: () => { assert.fail() } - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(false) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(err(WorkspaceNoAccessError)) }) it('allows project access via workspace role if user does not have project role', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'true' - }) - ), + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false, workspaceId: crs({ length: 10 }) }), - getProjectRole: () => Promise.resolve(err(ProjectRoleNotFoundError)), - getServerRole: () => { - assert.fail() - }, - getWorkspaceRole: () => Promise.resolve(ok('workspace:admin')), + getProjectRole: async () => err(ProjectRoleNotFoundError), + getServerRole: async () => ok('server:user'), + getWorkspaceRole: async () => ok('workspace:admin'), getWorkspaceSsoSession: () => { assert.fail() }, - getWorkspaceSsoProvider: () => - Promise.resolve(err(WorkspaceSsoProviderNotFoundError)) - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(true) + getWorkspaceSsoProvider: async () => err(WorkspaceSsoProviderNotFoundError) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(ok()) }) it('does not check SSO sessions if user is workspace guest', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'true' - }) - ), + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false, workspaceId: crs({ length: 10 }) }), - getProjectRole: () => Promise.resolve(ok('stream:contributor')), - getServerRole: () => { - assert.fail() - }, - getWorkspaceRole: () => Promise.resolve(ok('workspace:guest')), + getProjectRole: async () => ok('stream:contributor'), + getServerRole: async () => ok('server:user'), + getWorkspaceRole: async () => ok('workspace:guest'), getWorkspaceSsoSession: () => { assert.fail() }, getWorkspaceSsoProvider: () => { assert.fail() } - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(true) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(ok()) }) it('does not check SSO sessions if workspace does not have it enabled', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'true' - }) - ), + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false, workspaceId: crs({ length: 10 }) }), - getProjectRole: () => Promise.resolve(ok('stream:contributor')), - getServerRole: () => { - assert.fail() - }, - getWorkspaceRole: () => Promise.resolve(ok('workspace:member')), + getProjectRole: async () => ok('stream:contributor'), + getServerRole: async () => ok('server:user'), + getWorkspaceRole: async () => ok('workspace:member'), getWorkspaceSsoSession: () => { assert.fail() }, - getWorkspaceSsoProvider: () => - Promise.resolve(err(WorkspaceSsoProviderNotFoundError)) - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(true) + getWorkspaceSsoProvider: async () => err(WorkspaceSsoProviderNotFoundError) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(ok()) }) it('does not allow project access if SSO session is missing', async () => { - const canQueryProject = canQueryProjectPolicyFactory({ + const canQueryProject = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'true' - }) - ), + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false, workspaceId: crs({ length: 10 }) }), - getProjectRole: () => Promise.resolve(ok('stream:contributor')), - getServerRole: () => { - assert.fail() - }, - getWorkspaceRole: () => Promise.resolve(ok('workspace:member')), - getWorkspaceSsoSession: () => - Promise.resolve(err(WorkspaceSsoSessionNotFoundError)), - getWorkspaceSsoProvider: () => Promise.resolve(ok({ providerId: 'foo' })) + getProjectRole: async () => ok('stream:contributor'), + getServerRole: async () => ok('server:user'), + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoSession: async () => err(WorkspaceSsoSessionNotFoundError), + getWorkspaceSsoProvider: async () => ok({ providerId: 'foo' }) }) const canQuery = await canQueryProject(canQueryProjectArgs()) expect(canQuery.isOk).toBe(false) }) - it('does not allow project access if SSO session is expired or invalid', async () => { + it('does not allow project access if SSO session is not found', async () => { const date = new Date() date.setDate(date.getDate() - 1) - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'true' - }) - ), + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false, workspaceId: crs({ length: 10 }) }), - getProjectRole: () => Promise.resolve(ok('stream:contributor')), - getServerRole: () => { - assert.fail() - }, - getWorkspaceRole: () => Promise.resolve(ok('workspace:member')), - getWorkspaceSsoSession: () => - Promise.resolve(ok({ validUntil: date, userId: 'foo', providerId: 'foo' })), - getWorkspaceSsoProvider: () => Promise.resolve(ok({ providerId: 'foo' })) - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(false) + getProjectRole: async () => ok('stream:contributor'), + getServerRole: async () => ok('server:user'), + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoSession: async () => err(WorkspaceSsoSessionNotFoundError), + getWorkspaceSsoProvider: async () => ok({ providerId: 'foo' }) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(err(WorkspaceSsoSessionInvalidError)) + }) + it('does not allow project access if SSO session is expired', async () => { + const date = new Date() + date.setDate(date.getDate() - 1) + + const result = canQueryProjectPolicy({ + getEnv: async () => + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), + getProject: getProjectFake({ + isDiscoverable: false, + isPublic: false, + workspaceId: crs({ length: 10 }) + }), + getProjectRole: async () => ok('stream:contributor'), + getServerRole: async () => ok('server:user'), + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoSession: async () => + ok({ validUntil: date, userId: 'foo', providerId: 'foo' }), + getWorkspaceSsoProvider: async () => ok({ providerId: 'foo' }) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(err(WorkspaceSsoSessionInvalidError)) }) it('allows project access if SSO session is valid', async () => { const date = new Date() date.setDate(date.getDate() + 1) - const canQueryProject = canQueryProjectPolicyFactory({ + const result = canQueryProjectPolicy({ getEnv: async () => - ok( - parseFeatureFlags({ - FF_WORKSPACES_MODULE_ENABLED: 'true' - }) - ), + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'true' + }), getProject: getProjectFake({ isDiscoverable: false, isPublic: false, workspaceId: crs({ length: 10 }) }), - getProjectRole: () => Promise.resolve(ok('stream:contributor')), - getServerRole: () => { - assert.fail() - }, - getWorkspaceRole: () => Promise.resolve(ok('workspace:member')), - getWorkspaceSsoSession: () => - Promise.resolve(ok({ validUntil: date, userId: 'foo', providerId: 'foo' })), - getWorkspaceSsoProvider: () => Promise.resolve(ok({ providerId: 'foo' })) - }) - const canQuery = await canQueryProject(canQueryProjectArgs()) - expect(canQuery.isOk).toBe(true) + getProjectRole: async () => ok('stream:contributor'), + getServerRole: async () => ok('server:user'), + getWorkspaceRole: async () => ok('workspace:member'), + getWorkspaceSsoSession: async () => + ok({ validUntil: date, userId: 'foo', providerId: 'foo' }), + getWorkspaceSsoProvider: async () => ok({ providerId: 'foo' }) + })(canQueryProjectArgs()) + await expect(result).resolves.toStrictEqual(ok()) }) }) }) diff --git a/packages/shared/src/authz/policies/canQueryProject.ts b/packages/shared/src/authz/policies/canQueryProject.ts index cb4ee24ab..86da9e2f7 100644 --- a/packages/shared/src/authz/policies/canQueryProject.ts +++ b/packages/shared/src/authz/policies/canQueryProject.ts @@ -1,26 +1,23 @@ -import { - requireAnyWorkspaceRole, - requireMinimumWorkspaceRole -} from '../checks/workspaceRole.js' -import { - requireExactProjectVisibilityFactory, - requireMinimumProjectRoleFactory -} from '../checks/projects.js' -import { AuthPolicyFactory, ProjectContext, UserContext } from '../domain/policies.js' -import { requireExactServerRole } from '../checks/serverRole.js' -import { requireValidWorkspaceSsoSession } from '../checks/workspaceSso.js' +import { isJust } from 'true-myth/maybe' +import { err, isErr, isOk, ok } from 'true-myth/result' import { Roles } from '../../core/constants.js' +import { hasMinimumProjectRole, isPubliclyReadableProject } from '../checks/projects.js' import { ProjectNoAccessError, ProjectNotFoundError, + ServerNoAccessError, + ServerNoSessionError, WorkspaceNoAccessError, WorkspaceSsoSessionInvalidError } from '../domain/authErrors.js' -import { err, isOk, ok } from 'true-myth/result' import { AuthCheckContextLoaderKeys } from '../domain/loaders.js' -import { LogicError } from '../domain/errors.js' +import { AuthPolicy, MaybeUserContext, ProjectContext } from '../domain/policies.js' +import { canUseAdminOverride, hasMinimumServerRole } from '../checks/serverRole.js' +import { hasAnyWorkspaceRole } from '../checks/workspaceRole.js' +import { maybeMemberRoleWithValidSsoSessionIfNeeded } from '../fragments/workspaceSso.js' +import { throwUncoveredError } from '../../core/index.js' -export const canQueryProjectPolicyFactory: AuthPolicyFactory< +export const canQueryProjectPolicy: AuthPolicy< | typeof AuthCheckContextLoaderKeys.getEnv | typeof AuthCheckContextLoaderKeys.getProject | typeof AuthCheckContextLoaderKeys.getProjectRole @@ -28,7 +25,9 @@ export const canQueryProjectPolicyFactory: AuthPolicyFactory< | typeof AuthCheckContextLoaderKeys.getWorkspaceRole | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession, - UserContext & ProjectContext, + MaybeUserContext & ProjectContext, + | typeof ServerNoSessionError + | typeof ServerNoAccessError | typeof ProjectNotFoundError | typeof ProjectNoAccessError | typeof WorkspaceNoAccessError @@ -37,109 +36,70 @@ export const canQueryProjectPolicyFactory: AuthPolicyFactory< (loaders) => async ({ userId, projectId }) => { const env = await loaders.getEnv() - if (!isOk(env)) { - throw new LogicError('Failed to load environment variables') - } - - const { FF_ADMIN_OVERRIDE_ENABLED, FF_WORKSPACES_MODULE_ENABLED } = env.value + // we prerolad the project and early return any loading errors + // this is a short circuit in the frontend, to surface any project load errors + // from the backend. + // make sure to expose all of the error types in the loader type, + // that we care about in this early return const project = await loaders.getProject({ projectId }) - if (!isOk(project)) { - return err(project.error) + // if (isErr(project)) return err(project.error) + if (isErr(project)) { + switch (project.error.code) { + case 'ProjectNoAccess': + case 'ProjectNotFound': + case 'WorkspaceSsoSessionInvalid': + return err(project.error) + default: + throwUncoveredError(project.error) + } } // All users may read public projects - const isPublicResult = await requireExactProjectVisibilityFactory({ loaders })({ - projectId, - projectVisibility: 'public' - }) - if (isPublicResult) { - return ok(true) - } + if (await isPubliclyReadableProject(loaders)({ projectId })) return ok() - // All users may read link-shareable projects - const isLinkShareableResult = await requireExactProjectVisibilityFactory({ - loaders - })({ - projectId, - projectVisibility: 'linkShareable' + // From this point on, you cannot pass as an unknown user, need to log in + if (!userId) return err(ServerNoSessionError) + const isActiveServerUser = await hasMinimumServerRole(loaders)({ + userId, + role: Roles.Server.Guest }) - if (isLinkShareableResult) { - return ok(true) - } - // From this point on, you cannot pass as an unknown user - if (!userId) { - return err(ProjectNoAccessError) - } + if (!isActiveServerUser) return err(ServerNoAccessError) // 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 ok(true) - } - } + if (await canUseAdminOverride(loaders)({ userId })) return ok() const { workspaceId } = project.value - // When a project belongs to a workspace - if (FF_WORKSPACES_MODULE_ENABLED && !!workspaceId) { + if (env.FF_WORKSPACES_MODULE_ENABLED && !!workspaceId) { // User must have a workspace role to read project data - const hasWorkspaceRoleResult = await requireAnyWorkspaceRole({ loaders })({ + if (!(await hasAnyWorkspaceRole(loaders)({ userId, workspaceId }))) + return err(WorkspaceNoAccessError) + + const memberWithSsoSession = await maybeMemberRoleWithValidSsoSessionIfNeeded( + loaders + )({ userId, workspaceId }) - if (!hasWorkspaceRoleResult) { - // Should we hide the fact, the project is in a workspace? - return err(WorkspaceNoAccessError) - } - const hasMinimumMemberRole = await requireMinimumWorkspaceRole({ - loaders - })({ - userId, - workspaceId, - role: 'workspace:member' - }) - - if (hasMinimumMemberRole) { - const workspaceSsoProvider = await loaders.getWorkspaceSsoProvider({ - workspaceId - }) - if (workspaceSsoProvider.isOk) { - // Member and admin user must have a valid SSO session to read project data - const hasValidSsoSessionResult = await requireValidWorkspaceSsoSession({ - loaders - })({ - userId, - workspaceId - }) - if (!hasValidSsoSessionResult) { - return err(WorkspaceSsoSessionInvalidError) - } - } - - // Workspace members get to go through without an explicit project role - return ok(true) + if (isJust(memberWithSsoSession)) { + // if a member, make sure it has a valid sso session + return isOk(memberWithSsoSession.value) + ? ok() + : err(memberWithSsoSession.value.error) } else { // just fall through to the generic project role check for workspace:guest-s + // they do not need an sso session } } - // User must have at least stream reviewer role to read project data - const hasMinimumProjectRoleResult = await requireMinimumProjectRoleFactory({ - loaders - })({ + // User must have at least stream:reviewer role to read project data + return (await hasMinimumProjectRole(loaders)({ userId, projectId, role: 'stream:reviewer' - }) - if (hasMinimumProjectRoleResult) { - return ok(true) - } - return err(ProjectNoAccessError) + })) + ? ok() + : err(ProjectNoAccessError) } diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index f7538c332..cc565ef27 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -1,9 +1,9 @@ import { AllAuthCheckContextLoaders } from '../domain/loaders.js' -import { canQueryProjectPolicyFactory } from './canQueryProject.js' +import { canQueryProjectPolicy } from './canQueryProject.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ project: { - canQuery: canQueryProjectPolicyFactory(loaders) + canQuery: canQueryProjectPolicy(loaders) } })