Merge branch 'main' of github.com:specklesystems/speckle-server into alessandro/web-2944-versions-limits

This commit is contained in:
Alessandro Magionami
2025-04-11 14:54:13 +02:00
133 changed files with 5055 additions and 1278 deletions
@@ -127,6 +127,26 @@ export const ServerNoSessionError = defineAuthError({
message: 'You are not logged in to this server'
})
export const CommentNotFoundError = defineAuthError({
code: 'CommentNotFound',
message: 'Comment not found'
})
export const CommentNoAccessError = defineAuthError({
code: 'CommentNoAccess',
message: 'You do not have access to this comment'
})
export const ModelNotFoundError = defineAuthError({
code: 'ModelNotFound',
message: 'Model not found'
})
export const ReservedModelNotDeletableError = defineAuthError({
code: 'ReservedModelNotDeletable',
message: 'This model is reserved and cannot be deleted'
})
// Resolve all exported error types
export type AllAuthErrors = ValueOf<{
[key in keyof typeof import('./authErrors.js')]: typeof import('./authErrors.js')[key] extends new (
@@ -0,0 +1,6 @@
import { Comment } from './types.js'
export type GetComment = (args: {
commentId: string
projectId: string
}) => Promise<Comment | null>
@@ -0,0 +1,5 @@
export type Comment = {
id: string
authorId: string
projectId: string
}
@@ -1,4 +1,12 @@
export type ProjectContext = { projectId: string }
export type MaybeProjectContext = { projectId?: string }
export type UserContext = { userId: string }
export type MaybeUserContext = { userId?: string }
export type WorkspaceContext = { workspaceId: string }
export type MaybeWorkspaceContext = { workspaceId?: string }
export type CommentContext = { commentId: string }
export type ModelContext = { modelId: string }
+7 -1
View File
@@ -19,6 +19,8 @@ import type {
GetWorkspaceSsoProvider,
GetWorkspaceSsoSession
} from './workspaces/operations.js'
import { GetComment } from './comments/operations.js'
import { GetModel } from './models/operations.js'
// utility type that ensures all properties functions that return promises
type PromiseAll<T> = {
@@ -63,7 +65,9 @@ export const AuthCheckContextLoaderKeys = <const>{
getWorkspaceLimits: 'getWorkspaceLimits',
getWorkspaceSsoProvider: 'getWorkspaceSsoProvider',
getWorkspaceSsoSession: 'getWorkspaceSsoSession',
getAdminOverrideEnabled: 'getAdminOverrideEnabled'
getAdminOverrideEnabled: 'getAdminOverrideEnabled',
getComment: 'getComment',
getModel: 'getModel'
}
export const Loaders = AuthCheckContextLoaderKeys // shorter alias
/* v8 ignore end */
@@ -87,6 +91,8 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
getWorkspaceModelCount: GetWorkspaceModelCount
getWorkspaceSsoProvider: GetWorkspaceSsoProvider
getWorkspaceSsoSession: GetWorkspaceSsoSession
getComment: GetComment
getModel: GetModel
}>
export type AuthCheckContextLoaders<
@@ -0,0 +1,6 @@
import { Model } from './types.js'
export type GetModel = (args: {
projectId: string
modelId: string
}) => Promise<Model | null>
@@ -0,0 +1,6 @@
export type Model = {
id: string
projectId: string
name: string
authorId: string | null
}
@@ -6,6 +6,7 @@ export type Project = {
isDiscoverable: boolean
isPublic: boolean
workspaceId: string | null
allowPublicComments: boolean
}
export type ProjectVisibility = 'public' | 'linkShareable' | 'private'
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
checkIfPubliclyReadableProjectFragment,
ensureImplicitProjectMemberWithReadAccessFragment,
ensureImplicitProjectMemberWithWriteAccessFragment,
ensureMinimumProjectRoleFragment,
ensureProjectWorkspaceAccessFragment
} from './projects.js'
@@ -16,11 +17,12 @@ import {
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { OverridesOf } from '../../tests/helpers/types.js'
import { getProjectFake } from '../../tests/fakes.js'
describe('ensureMinimumProjectRoleFragment', () => {
const buildSUT = (overrides?: OverridesOf<typeof ensureMinimumProjectRoleFragment>) =>
ensureMinimumProjectRoleFragment({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -40,7 +42,7 @@ describe('ensureMinimumProjectRoleFragment', () => {
overrides?: OverridesOf<typeof ensureMinimumProjectRoleFragment>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: 'workspaceId',
isDiscoverable: false,
@@ -160,7 +162,7 @@ describe('checkIfPubliclyReadableProjectFragment', () => {
overrides?: OverridesOf<typeof checkIfPubliclyReadableProjectFragment>
) =>
checkIfPubliclyReadableProjectFragment({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -192,7 +194,7 @@ describe('checkIfPubliclyReadableProjectFragment', () => {
it('returns true if project is public', async () => {
const sut = buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -209,7 +211,7 @@ describe('checkIfPubliclyReadableProjectFragment', () => {
it('returns false if project is not public', async () => {
const sut = buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -228,7 +230,7 @@ describe('ensureProjectWorkspaceAccessFragment', () => {
overrides?: OverridesOf<typeof ensureProjectWorkspaceAccessFragment>
) =>
ensureProjectWorkspaceAccessFragment({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -246,7 +248,7 @@ describe('ensureProjectWorkspaceAccessFragment', () => {
overrides?: OverridesOf<typeof ensureProjectWorkspaceAccessFragment>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: 'workspaceId',
isDiscoverable: false,
@@ -384,7 +386,7 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
overrides?: OverridesOf<typeof ensureImplicitProjectMemberWithReadAccessFragment>
) =>
ensureImplicitProjectMemberWithReadAccessFragment({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
@@ -405,7 +407,7 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
overrides?: OverridesOf<typeof ensureImplicitProjectMemberWithReadAccessFragment>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'projectId',
workspaceId: 'workspaceId',
isDiscoverable: false,
@@ -599,3 +601,275 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
})
})
})
describe('ensureImplicitProjectMemberWithWriteAccessFragment', () => {
const buildSUT = (
overrides?: OverridesOf<typeof ensureImplicitProjectMemberWithWriteAccessFragment>
) =>
ensureImplicitProjectMemberWithWriteAccessFragment({
getProject: getProjectFake({
id: 'projectId',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getServerRole: async () => Roles.Server.User,
getProjectRole: async () => Roles.Stream.Contributor,
getEnv: async () => parseFeatureFlags({}),
getWorkspace: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspaceRole: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof ensureImplicitProjectMemberWithWriteAccessFragment>
) =>
buildSUT({
getProject: getProjectFake({
id: 'projectId',
workspaceId: 'workspaceId',
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspaceId',
slug: 'workspaceSlug'
}),
getWorkspaceSsoProvider: async () => ({
providerId: 'ssoProviderId'
}),
getWorkspaceSsoSession: async () => ({
providerId: 'ssoSessionId',
userId: 'userId',
validUntil: new Date(Date.now() + 1000 * 60 * 60 * 24)
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
...overrides
})
it('succeeds with explicit member role', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthOKResult()
})
it('fails if user not specified', async () => {
const sut = buildSUT()
const result = await sut({
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if user is guest and asking for owner', async () => {
const sut = buildSUT({
getServerRole: async () => Roles.Server.Guest
})
const result = await sut({
userId: 'userId',
projectId: 'projectId',
role: Roles.Stream.Owner
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails w/o role even if admin', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getServerRole: async () => Roles.Server.Admin
})
const result = await sut({
userId: 'userId',
projectId: 'projectId',
role: Roles.Stream.Reviewer
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails without project role', async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds with reviewer role, if permitted', async () => {
const sut = buildSUT({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'userId',
projectId: 'projectId',
role: Roles.Stream.Reviewer
})
expect(result).toBeAuthOKResult()
})
it('fails with a too restrictive project role', async () => {
const sut = buildSUT({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
describe('with workspace project', () => {
it('succeeds with implicit project role', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthOKResult()
})
it('fails if workspace role not permissive enough', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/ low workspace role if allowed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId',
role: Roles.Stream.Reviewer
})
expect(result).toBeAuthOKResult()
})
it('succeeds w/o sso session, if workspace guest w/ explicit project role', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Guest,
getWorkspaceSsoSession: async () => null,
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthOKResult()
})
it('succeeds w/o sso session, if not configured', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthOKResult()
})
it('fails if no sso session, but required', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails if sso session expired', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
providerId: 'ssoSessionId',
userId: 'userId',
validUntil: new Date(Date.now() - 1000 * 60 * 60 * 24)
})
})
const result = await sut({
userId: 'userId',
projectId: 'projectId'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -187,15 +187,6 @@ export const ensureImplicitProjectMemberWithReadAccessFragment: AuthPolicyEnsure
}
if (isAdminOverrideEnabled.value) return ok()
// No god mode, ensure workspace access
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
// And ensure (implicit/explicit) project role
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
@@ -206,5 +197,82 @@ export const ensureImplicitProjectMemberWithReadAccessFragment: AuthPolicyEnsure
return err(ensuredProjectRole.error)
}
// No god mode, ensure workspace access
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
return ok()
}
/**
* Ensure user has implicit/explicit project membership and write access
*/
export const ensureImplicitProjectMemberWithWriteAccessFragment: AuthPolicyEnsureFragment<
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getServerRole
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext &
ProjectContext & {
/**
* By default assumes Contributor+ for any writes, but some operations
* may allow for lower roles (e.g. comments)
*/
role?: StreamRoles
},
InstanceType<
| typeof ProjectNotFoundError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ProjectNoAccessError
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
>
> =
(loaders) =>
async ({ userId, projectId, role }) => {
const requiredProjectRole = role || Roles.Stream.Contributor
const requiredServerRole =
requiredProjectRole === Roles.Stream.Owner
? Roles.Server.User
: Roles.Server.Guest
// Ensure user is authed
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: requiredServerRole
})
if (ensuredServerRole.isErr) {
return err(ensuredServerRole.error)
}
// And ensure (implicit/explicit) project role
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
projectId,
role: requiredProjectRole
})
if (ensuredProjectRole.isErr) {
return err(ensuredProjectRole.error)
}
// Ensure workspace access
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
return ok()
}
@@ -1,13 +1,31 @@
import { err, ok } from 'true-myth/result'
import { AuthPolicyEnsureFragment } from '../domain/policies.js'
import { hasMinimumWorkspaceRole } from '../checks/workspaceRole.js'
import {
hasAnyWorkspaceRole,
hasMinimumWorkspaceRole
} from '../checks/workspaceRole.js'
import {
ProjectNotFoundError,
WorkspaceLimitsReachedError,
WorkspaceNoAccessError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspaceReadOnlyError,
WorkspacesNotEnabledError,
WorkspaceSsoSessionNoAccessError
} from '../domain/authErrors.js'
import { Loaders } from '../domain/loaders.js'
import { Roles, WorkspaceRoles } from '../../core/constants.js'
import {
MaybeUserContext,
ProjectContext,
WorkspaceContext
} from '../domain/context.js'
import {
isNewWorkspacePlan,
isWorkspacePlanStatusReadOnly
} from '../../workspaces/helpers/plans.js'
import { hasEditorSeat } from '../checks/workspaceSeat.js'
/**
* Ensure user has a workspace role, and a valid SSO session (if SSO is configured)
@@ -84,3 +102,154 @@ export const ensureWorkspacesEnabledFragment: AuthPolicyEnsureFragment<
if (!env.FF_WORKSPACES_MODULE_ENABLED) return err(new WorkspacesNotEnabledError())
return ok()
}
/**
* Ensure workspace is not read-only
*/
export const ensureWorkspaceNotReadOnlyFragment: AuthPolicyEnsureFragment<
typeof Loaders.getWorkspacePlan,
WorkspaceContext,
InstanceType<typeof WorkspaceNoAccessError | typeof WorkspaceReadOnlyError>
> =
(loaders) =>
async ({ workspaceId }) => {
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
if (isWorkspacePlanStatusReadOnly(workspacePlan.status))
return err(new WorkspaceReadOnlyError())
return ok()
}
/**
* Ensure workspace can accept new project (not read-only, limits not reached).
* If userId is specified, will also check for user role & seat
*/
export const ensureWorkspaceProjectCanBeCreatedFragment: AuthPolicyEnsureFragment<
| typeof Loaders.getWorkspacePlan
| typeof Loaders.getWorkspaceSeat
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspaceLimits
| typeof Loaders.getWorkspaceProjectCount,
WorkspaceContext & MaybeUserContext,
InstanceType<
| typeof WorkspaceNoAccessError
| typeof WorkspaceReadOnlyError
| typeof WorkspaceLimitsReachedError
| typeof WorkspaceNoEditorSeatError
| typeof WorkspaceNotEnoughPermissionsError
>
> =
(loaders) =>
async ({ workspaceId, userId }) => {
// First check user even has access
if (userId) {
// Is Member+
const isNotGuest = await hasMinimumWorkspaceRole(loaders)({
userId,
workspaceId,
role: Roles.Workspace.Member
})
if (!isNotGuest) {
return err(
new WorkspaceNotEnoughPermissionsError(
'Guests cannot create projects in the workspace'
)
)
}
}
const ensuredNotReadOnly = await ensureWorkspaceNotReadOnlyFragment(loaders)({
workspaceId
})
if (ensuredNotReadOnly.isErr) return err(ensuredNotReadOnly.error)
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
// Now check editor seat
if (userId) {
if (isNewWorkspacePlan(workspacePlan.name)) {
const isEditor = await hasEditorSeat(loaders)({
userId,
workspaceId
})
if (!isEditor) return err(new WorkspaceNoEditorSeatError())
}
}
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
if (!workspaceLimits) return err(new WorkspaceNoAccessError())
// no limits imposed
if (workspaceLimits.projectCount === null) return ok()
const currentProjectCount = await loaders.getWorkspaceProjectCount({
workspaceId
})
// this will not happen in practice
if (currentProjectCount === null) return err(new WorkspaceNoAccessError())
return currentProjectCount < workspaceLimits.projectCount
? ok()
: err(new WorkspaceLimitsReachedError({ payload: { limit: 'projectCount' } }))
}
/**
* Ensure model can be created (workspace not read-only, limits not reached).
* If userId is specified, will also check for appropriate user role & seat
*/
export const ensureModelCanBeCreatedFragment: AuthPolicyEnsureFragment<
| typeof Loaders.getWorkspacePlan
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspaceLimits
| typeof Loaders.getProject
| typeof Loaders.getWorkspaceModelCount,
ProjectContext & MaybeUserContext,
InstanceType<
| typeof WorkspaceNoAccessError
| typeof WorkspaceReadOnlyError
| typeof WorkspaceLimitsReachedError
| typeof ProjectNotFoundError
>
> =
(loaders) =>
async ({ projectId, userId }) => {
const project = await loaders.getProject({ projectId })
if (!project) return err(new ProjectNotFoundError())
const { workspaceId } = project
if (!workspaceId) return ok()
if (userId) {
// Has workspace role
const isInWorkspace = await hasAnyWorkspaceRole(loaders)({
userId,
workspaceId
})
if (!isInWorkspace) {
return err(new WorkspaceNoAccessError())
}
}
const ensuredNotReadOnly = await ensureWorkspaceNotReadOnlyFragment(loaders)({
workspaceId
})
if (ensuredNotReadOnly.isErr) return err(ensuredNotReadOnly.error)
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
if (!workspaceLimits) return err(new WorkspaceNoAccessError())
if (workspaceLimits.modelCount === null) return ok()
const currentModelCount = await loaders.getWorkspaceModelCount({ workspaceId })
if (currentModelCount === null) return err(new WorkspaceNoAccessError())
return currentModelCount < workspaceLimits.modelCount
? ok()
: err(new WorkspaceLimitsReachedError({ payload: { limit: 'modelCount' } }))
}
+18 -2
View File
@@ -1,7 +1,7 @@
import { AllAuthCheckContextLoaders } from '../domain/loaders.js'
import { canCreateWorkspaceProjectPolicy } from './workspace/canCreateWorkspaceProject.js'
import { canReadProjectPolicy } from './project/canReadProject.js'
import { canCreateModelPolicy } from './project/canCreateModel.js'
import { canCreateModelPolicy } from './project/model/canCreate.js'
import { canMoveToWorkspacePolicy } from './project/canMoveToWorkspace.js'
import { canCreatePersonalProjectPolicy } from './project/canCreatePersonal.js'
import { canUpdateProjectPolicy } from './project/canUpdate.js'
@@ -9,11 +9,27 @@ import { canReadProjectSettingsPolicy } from './project/canReadSettings.js'
import { canReadProjectWebhooksPolicy } from './project/canReadWebhooks.js'
import { canUpdateProjectAllowPublicCommentsPolicy } from './project/canUpdateAllowPublicComments.js'
import { canLeaveProjectPolicy } from './project/canLeave.js'
import { canBroadcastProjectActivityPolicy } from './project/canBroadcastActivity.js'
import { canCreateProjectCommentPolicy } from './project/comment/canCreate.js'
import { canArchiveProjectCommentPolicy } from './project/comment/canArchive.js'
import { canEditProjectCommentPolicy } from './project/comment/canEdit.js'
import { canUpdateModelPolicy } from './project/model/canUpdate.js'
import { canDeleteModelPolicy } from './project/model/canDelete.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
project: {
model: {
canCreate: canCreateModelPolicy(loaders),
canUpdate: canUpdateModelPolicy(loaders),
canDelete: canDeleteModelPolicy(loaders)
},
comment: {
canCreate: canCreateProjectCommentPolicy(loaders),
canArchive: canArchiveProjectCommentPolicy(loaders),
canEdit: canEditProjectCommentPolicy(loaders)
},
canBroadcastActivity: canBroadcastProjectActivityPolicy(loaders),
canRead: canReadProjectPolicy(loaders),
canCreateModel: canCreateModelPolicy(loaders),
canMoveToWorkspace: canMoveToWorkspacePolicy(loaders),
canCreatePersonal: canCreatePersonalProjectPolicy(loaders),
canUpdate: canUpdateProjectPolicy(loaders),
@@ -0,0 +1,265 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../tests/helpers/types.js'
import { canBroadcastProjectActivityPolicy } from './canBroadcastActivity.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { Roles } from '../../../core/constants.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
describe('canBroadcastProjectActivityPolicy', () => {
const buildSUT = (
overrides?: OverridesOf<typeof canBroadcastProjectActivityPolicy>
) =>
canBroadcastProjectActivityPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getAdminOverrideEnabled: async () => false,
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof canBroadcastProjectActivityPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
it('succeeds w/ project role', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o project role if public', async () => {
const sut = buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true
}),
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails if user undefined', async () => {
const sut = buildSUT()
const result = await sut({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if project not found', async () => {
const sut = buildSUT({
getProject: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('fails if user has no project role', async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/ admin override, even w/o project role', async () => {
const sut = buildSUT({
getAdminOverrideEnabled: async () => true,
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
describe('with workspace project', async () => {
it('succeeds w/ workspace role', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o project & workspace role if public', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => null,
getProjectRole: async () => null,
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: true
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails if user has no workspace role', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
it('succeeds w/o sso, if not needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o sso, if needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails if sso expired', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() - 1000)
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,70 @@
import { err, ok } from 'true-myth/result'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { MaybeUserContext, ProjectContext } from '../../domain/context.js'
import { Loaders } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
checkIfPubliclyReadableProjectFragment,
ensureImplicitProjectMemberWithReadAccessFragment
} from '../../fragments/projects.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
export const canBroadcastProjectActivityPolicy: AuthPolicy<
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getServerRole
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole
| typeof Loaders.getAdminOverrideEnabled,
MaybeUserContext & ProjectContext,
InstanceType<
| typeof ProjectNotFoundError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ProjectNoAccessError
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
>
> =
(loaders) =>
async ({ userId, projectId }) => {
// Ensure logged in
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId
})
if (ensuredServerRole.isErr) {
return err(ensuredServerRole.error)
}
// If publicly readable - any authed user can broadcast
const isPubliclyReadable = await checkIfPubliclyReadableProjectFragment(loaders)({
projectId
})
if (isPubliclyReadable.isErr) {
return err(isPubliclyReadable.error)
}
if (isPubliclyReadable.value) return ok()
// Not public. Ensure user has at least implicit membership & read access
const hasReadAccess = await ensureImplicitProjectMemberWithReadAccessFragment(
loaders
)({
userId,
projectId
})
if (hasReadAccess.isErr) {
return err(hasReadAccess.error)
}
return ok()
}
@@ -1,102 +0,0 @@
import { err, ok } from 'true-myth/result'
import {
ProjectNotFoundError,
ProjectNoAccessError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError,
WorkspaceLimitsReachedError,
ServerNoSessionError,
ServerNoAccessError,
WorkspaceReadOnlyError
} from '../../domain/authErrors.js'
import { MaybeUserContext, ProjectContext } from '../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import { Roles } from '../../../core/constants.js'
import { isWorkspacePlanStatusReadOnly } from '../../../workspaces/index.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import {
ensureMinimumProjectRoleFragment,
ensureProjectWorkspaceAccessFragment
} from '../../fragments/projects.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getProject
| typeof AuthCheckContextLoaderKeys.getProjectRole
| typeof AuthCheckContextLoaderKeys.getServerRole
| typeof AuthCheckContextLoaderKeys.getWorkspace
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceLimits
| typeof AuthCheckContextLoaderKeys.getWorkspaceModelCount
type PolicyArgs = MaybeUserContext & ProjectContext
type PolicyErrors =
| InstanceType<typeof ProjectNotFoundError>
| InstanceType<typeof ProjectNoAccessError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspaceReadOnlyError>
| InstanceType<typeof WorkspaceLimitsReachedError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNoAccessError>
export const canCreateModelPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, projectId }) => {
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.Guest
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
projectId,
role: Roles.Stream.Contributor
})
if (ensuredProjectRole.isErr) return err(ensuredProjectRole.error)
const project = await loaders.getProject({ projectId })
// Projects outside of a workspace do not need to check workspace limits
if (!project?.workspaceId) {
return ok()
}
const { workspaceId } = project
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
if (isWorkspacePlanStatusReadOnly(workspacePlan.status))
return err(new WorkspaceReadOnlyError())
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
if (!workspaceLimits) return err(new WorkspaceNoAccessError())
if (workspaceLimits.modelCount === null) return ok()
const currentModelCount = await loaders.getWorkspaceModelCount({ workspaceId })
if (currentModelCount === null) return err(new WorkspaceNoAccessError())
return currentModelCount < workspaceLimits.modelCount
? ok()
: err(new WorkspaceLimitsReachedError({ payload: { limit: 'modelCount' } }))
}
@@ -10,12 +10,13 @@ import {
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
describe('canLeaveProjectPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canLeaveProjectPolicy>) =>
canLeaveProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -33,7 +34,7 @@ describe('canLeaveProjectPolicy', () => {
const buildWorkspaceSUT = (overrides?: OverridesOf<typeof canLeaveProjectPolicy>) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
@@ -3,7 +3,7 @@ import { assert, describe, expect, it } from 'vitest'
import { canMoveToWorkspacePolicy } from './canMoveToWorkspace.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { Project } from '../../domain/projects/types.js'
import { Roles } from '../../../core/constants.js'
import { Roles, SeatTypes } from '../../../core/constants.js'
import { Workspace } from '../../domain/workspaces/types.js'
import { WorkspacePlan } from '../../../workspaces/index.js'
@@ -27,6 +27,7 @@ const buildCanMoveToWorkspace = (
getWorkspaceRole: async () => {
return Roles.Workspace.Admin
},
getWorkspaceSeat: async () => SeatTypes.Editor,
getWorkspaceSsoProvider: async () => {
return null
},
@@ -139,4 +140,32 @@ describe('canMoveToWorkspacePolicy returns a function, that', () => {
const result = await buildCanMoveToWorkspace({})(canMoveToWorkspaceArgs())
expect(result).toBeAuthOKResult()
})
it('allows validation without providing a project id', async () => {
const result = await buildCanMoveToWorkspace({
getProject: async () => {
assert.fail()
},
getProjectRole: async () => {
return null
}
})({
userId: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
})
expect(result).toBeAuthOKResult()
})
it('allows validation without providing a workspace id', async () => {
const result = await buildCanMoveToWorkspace({
getWorkspace: async () => {
assert.fail()
},
getWorkspaceRole: async () => {
return null
}
})({
userId: cryptoRandomString({ length: 9 }),
projectId: cryptoRandomString({ length: 9 })
})
expect(result).toBeAuthOKResult()
})
})
@@ -6,21 +6,23 @@ import {
ServerNoSessionError,
WorkspaceLimitsReachedError,
WorkspaceNoAccessError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspaceProjectMoveInvalidError,
WorkspaceReadOnlyError,
WorkspacesNotEnabledError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import {
MaybeProjectContext,
MaybeUserContext,
ProjectContext,
WorkspaceContext
MaybeWorkspaceContext
} from '../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import { Roles } from '../../../core/constants.js'
import { isWorkspacePlanStatusReadOnly } from '../../../workspaces/index.js'
import {
ensureWorkspaceProjectCanBeCreatedFragment,
ensureWorkspaceRoleAndSessionFragment,
ensureWorkspacesEnabledFragment
} from '../../fragments/workspaces.js'
@@ -39,8 +41,9 @@ type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceLimits
| typeof AuthCheckContextLoaderKeys.getWorkspaceProjectCount
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
type PolicyArgs = MaybeUserContext & ProjectContext & WorkspaceContext
type PolicyArgs = MaybeUserContext & MaybeProjectContext & MaybeWorkspaceContext
type PolicyErrors =
| InstanceType<typeof ProjectNotFoundError>
@@ -53,6 +56,8 @@ type PolicyErrors =
| InstanceType<typeof WorkspaceProjectMoveInvalidError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNoAccessError>
| InstanceType<typeof WorkspaceNoEditorSeatError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
export const canMoveToWorkspacePolicy: AuthPolicy<
PolicyLoaderKeys,
@@ -64,48 +69,47 @@ export const canMoveToWorkspacePolicy: AuthPolicy<
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
// We do not support moving projects that are already in a workspace
const project = await loaders.getProject({ projectId })
if (!project) return err(new ProjectNotFoundError())
if (!!project.workspaceId) return err(new WorkspaceProjectMoveInvalidError())
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
projectId,
role: Roles.Stream.Owner
})
if (ensuredProjectRole.isErr) return err(ensuredProjectRole.error)
if (projectId) {
// We do not support moving projects that are already in a workspace
const project = await loaders.getProject({ projectId })
if (!project) return err(new ProjectNotFoundError())
if (!!project.workspaceId) return err(new WorkspaceProjectMoveInvalidError())
const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)(
{
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
projectId,
role: Roles.Stream.Owner
})
if (ensuredProjectRole.isErr) return err(ensuredProjectRole.error)
}
if (workspaceId) {
const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(
loaders
)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Admin
})
if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error)
// Ensure workspace accepts new projects
const ensuredProjectsAccepted = await ensureWorkspaceProjectCanBeCreatedFragment(
loaders
)({
workspaceId,
userId
})
if (ensuredProjectsAccepted.isErr) {
return err(ensuredProjectsAccepted.error)
}
)
if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error)
}
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
if (isWorkspacePlanStatusReadOnly(workspacePlan.status))
return err(new WorkspaceReadOnlyError())
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
if (!workspaceLimits) return err(new WorkspaceNoAccessError())
if (workspaceLimits.projectCount === null) return ok()
const currentProjectCount = await loaders.getWorkspaceProjectCount({ workspaceId })
if (currentProjectCount === null) return err(new WorkspaceNoAccessError())
return currentProjectCount < workspaceLimits.projectCount
? ok()
: err(new WorkspaceLimitsReachedError({ payload: { limit: 'projectCount' } }))
return ok()
}
@@ -10,12 +10,13 @@ import {
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
describe('canReadProjectSettingsPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canReadProjectSettingsPolicy>) =>
canReadProjectSettingsPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -35,7 +36,7 @@ describe('canReadProjectSettingsPolicy', () => {
overrides?: OverridesOf<typeof canReadProjectSettingsPolicy>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
@@ -10,12 +10,13 @@ import {
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { canReadProjectWebhooksPolicy } from './canReadWebhooks.js'
import { getProjectFake } from '../../../tests/fakes.js'
describe('canReadProjectWebhooksPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canReadProjectWebhooksPolicy>) =>
canReadProjectWebhooksPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -35,7 +36,7 @@ describe('canReadProjectWebhooksPolicy', () => {
overrides?: OverridesOf<typeof canReadProjectWebhooksPolicy>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
@@ -9,12 +9,13 @@ import {
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
// Default deps allow test to succeed, this makes it so that we need to override less of them
const buildSUT = (overrides?: Partial<Parameters<typeof canUpdateProjectPolicy>[0]>) =>
canUpdateProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -33,7 +34,7 @@ const buildWorkspaceSUT = (
overrides?: Partial<Parameters<typeof canUpdateProjectPolicy>[0]>
) =>
buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
@@ -47,11 +48,15 @@ const buildWorkspaceSUT = (
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
getWorkspaceSsoSession: async () => {
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 7)
return {
userId: 'user-id',
providerId: 'provider-id',
validUntil
}
},
...overrides
})
@@ -2,11 +2,7 @@ import { err, ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../domain/context.js'
import { AuthPolicy } from '../../domain/policies.js'
import { Roles } from '../../../core/constants.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import {
ensureMinimumProjectRoleFragment,
ensureProjectWorkspaceAccessFragment
} from '../../fragments/projects.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../fragments/projects.js'
import { Loaders } from '../../domain/loaders.js'
import {
ProjectNoAccessError,
@@ -38,29 +34,16 @@ export const canUpdateProjectPolicy: AuthPolicy<
> =
(loaders) =>
async ({ userId, projectId }) => {
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
// Ensure proper project owner level write access
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) {
return err(ensuredServerRole.error)
}
const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({
userId: userId!,
projectId
})
if (ensuredWorkspaceAccess.isErr) {
return err(ensuredWorkspaceAccess.error)
}
const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
projectId,
role: Roles.Stream.Owner
})
if (ensuredProjectRole.isErr) {
return err(ensuredProjectRole.error)
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
@@ -4,6 +4,7 @@ import { canUpdateProjectAllowPublicCommentsPolicy } from './canUpdateAllowPubli
import { parseFeatureFlags } from '../../../environment/index.js'
import { Roles } from '../../../core/constants.js'
import { ProjectNoAccessError } from '../../domain/authErrors.js'
import { getProjectFake } from '../../../tests/fakes.js'
describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
const buildSUT = (
@@ -11,7 +12,7 @@ describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
) =>
canUpdateProjectAllowPublicCommentsPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -39,7 +40,7 @@ describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
it('succeeds if discoverable project', async () => {
const sut = buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: true,
@@ -72,7 +73,7 @@ describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
it('fails if project is neither public nor discoverable', async () => {
const sut = buildSUT({
getProject: async () => ({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
@@ -0,0 +1,321 @@
import { describe, expect, it } from 'vitest'
import { canArchiveProjectCommentPolicy } from './canArchive.js'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getCommentFake, getProjectFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
CommentNotFoundError,
ProjectNoAccessError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
describe('canArchiveProjectCommentPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canArchiveProjectCommentPolicy>) =>
canArchiveProjectCommentPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getComment: getCommentFake({
id: 'comment-id',
authorId: 'user-id',
projectId: 'project-id'
}),
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof canArchiveProjectCommentPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
it('can archive own comment', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can't archive own comment w/o project roles", async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it("can archive others' comments if owner", async () => {
const sut = buildSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getProjectRole: async () => Roles.Stream.Owner
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can't archive others' comments if not owner", async () => {
const sut = buildSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails if user not defined', async () => {
const sut = buildSUT()
const result = await sut({
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if comment not found', async () => {
const sut = buildSUT({
getComment: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'aaaaaaaaaaaa',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: CommentNotFoundError.code
})
})
describe('with workspace project', () => {
it('can archive own comment', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can archive others' comments if workspace admin", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Admin
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can archive others' comments as admin w/o sso, if not needed", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can arhive others' comments if explicit project owner", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getProjectRole: async () => Roles.Stream.Owner
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it("can't archive others' comments if not owner", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Member
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it("can't archive others' comments as owner, if no sso session", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it("can't archive others' comments as owner, if sso session expired", async () => {
const sut = buildWorkspaceSUT({
getComment: getCommentFake({
id: 'comment-id',
authorId: 'other-user-id',
projectId: 'project-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(0)
})
})
const result = await sut({
userId: 'user-id',
commentId: 'comment-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,75 @@
import { err, ok } from 'true-myth/result'
import { AuthPolicy } from '../../../domain/policies.js'
import {
CommentContext,
MaybeUserContext,
ProjectContext
} from '../../../domain/context.js'
import { Loaders } from '../../../domain/loaders.js'
import {
CommentNotFoundError,
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Roles } from '../../../../core/constants.js'
import { canCreateProjectCommentPolicy } from './canCreate.js'
export const canArchiveProjectCommentPolicy: AuthPolicy<
| typeof Loaders.getServerRole
| typeof Loaders.getComment
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext & CommentContext & ProjectContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof WorkspaceSsoSessionNoAccessError
| typeof CommentNotFoundError
>
> =
(loaders) =>
async ({ userId, commentId, projectId }) => {
// Includes canCreate check (checks general comment write access,
// cause just owning a comment is not enough, if you've been banned from it)
const canCreate = await canCreateProjectCommentPolicy(loaders)({
userId,
projectId
})
if (canCreate.isErr) {
return err(canCreate.error)
}
// Check that comment exists
const comment = await loaders.getComment({ commentId, projectId })
if (!comment) return err(new CommentNotFoundError())
// If user is owner, no extra checks necessary
if (comment.authorId === userId) return ok()
// Otherwise Ensure proper project owner level write access
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Owner
})
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
}
@@ -0,0 +1,294 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { canCreateProjectCommentPolicy } from './canCreate.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getProjectFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
describe('canCreateProjectCommentPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canCreateProjectCommentPolicy>) =>
canCreateProjectCommentPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof canCreateProjectCommentPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
it('succeeds w/ explicit project role', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o project role if public and public comments allowed', async () => {
const sut = buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true,
allowPublicComments: true
}),
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o project role', async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails w/o project role if public, but no public comments allowed', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true,
allowPublicComments: false
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails if user undefined', async () => {
const sut = buildSUT()
const result = await sut({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if project not found', async () => {
const sut = buildSUT({
getProject: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('fails w/o project role, even if admin', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getServerRole: async () => Roles.Server.Admin
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
describe('with workspace project', () => {
it('succeeds w/ implicit project role', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/ explicit project role, if guest', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => Roles.Workspace.Guest
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o project role, if only guest', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => null,
getWorkspaceRole: async () => Roles.Workspace.Guest
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/o session, if guest w/ explicit role', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => Roles.Workspace.Guest,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o session, if not needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeOKResult()
})
it('fails w/o session, if needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails w/ expired session', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() - 1000)
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,66 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { AuthPolicy } from '../../../domain/policies.js'
import { ensureMinimumServerRoleFragment } from '../../../fragments/server.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Roles } from '../../../../core/constants.js'
export const canCreateProjectCommentPolicy: AuthPolicy<
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext & ProjectContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof WorkspaceSsoSessionNoAccessError
>
> =
(loaders) =>
async ({ userId, projectId }) => {
// Ensure server access
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId
})
if (ensuredServerRole.isErr) {
return err(ensuredServerRole.error)
}
// Check if public commenting enabled
const project = await loaders.getProject({ projectId })
if (!project) return err(new ProjectNotFoundError())
const allowPublicCommenting =
(project.isPublic || project.isDiscoverable) && project.allowPublicComments
if (allowPublicCommenting) return ok()
// Not public, ensure proper project write access
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Reviewer
})
if (ensuredWriteAccess.isErr) {
return err(ensuredWriteAccess.error)
}
return ok()
}
@@ -0,0 +1,377 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getCommentFake, getProjectFake } from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
CommentNoAccessError,
CommentNotFoundError,
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { canEditProjectCommentPolicy } from './canEdit.js'
describe('canEditProjectCommentPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canEditProjectCommentPolicy>) =>
canEditProjectCommentPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => Roles.Stream.Reviewer,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getComment: getCommentFake({
id: 'comment-id',
projectId: 'project-id',
authorId: 'user-id'
}),
...overrides
})
const buildWorkspaceSUT = (
overrides?: OverridesOf<typeof canEditProjectCommentPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false,
allowPublicComments: false
}),
getProjectRole: async () => null,
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
it('succeeds w/ explicit project role', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o project role if public and public comments allowed', async () => {
const sut = buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true,
allowPublicComments: true
}),
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('fails if not the author, even if admin', async () => {
const sut = buildSUT({
getComment: getCommentFake({
id: 'comment-id',
projectId: 'project-id',
authorId: 'other-user-id'
}),
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => Roles.Stream.Owner
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: CommentNoAccessError.code
})
})
it('fails if comment not found', async () => {
const sut = buildSUT({
getComment: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: CommentNotFoundError.code
})
})
it('fails w/o project role', async () => {
const sut = buildSUT({
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails w/o project role if public, but no public comments allowed', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: true,
allowPublicComments: false
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('fails if user undefined', async () => {
const sut = buildSUT()
const result = await sut({
userId: undefined,
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('fails if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if project not found', async () => {
const sut = buildSUT({
getProject: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('fails w/o project role, even if admin', async () => {
const sut = buildSUT({
getProjectRole: async () => null,
getServerRole: async () => Roles.Server.Admin
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
describe('with workspace project', () => {
it('succeeds w/ implicit project role', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/ explicit project role, if guest', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => Roles.Workspace.Guest
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('fails if not the author, even if admin', async () => {
const sut = buildSUT({
getComment: getCommentFake({
id: 'comment-id',
projectId: 'project-id',
authorId: 'other-user-id'
}),
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => Roles.Stream.Owner,
getWorkspaceRole: async () => Roles.Workspace.Admin
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: CommentNoAccessError.code
})
})
it('fails w/o project role, if only guest', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => null,
getWorkspaceRole: async () => Roles.Workspace.Guest
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('succeeds w/o session, if guest w/ explicit role', async () => {
const sut = buildWorkspaceSUT({
getProjectRole: async () => Roles.Stream.Reviewer,
getWorkspaceRole: async () => Roles.Workspace.Guest,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('succeeds w/o session, if not needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null,
getWorkspaceSsoProvider: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeOKResult()
})
it('fails w/o session, if needed', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('fails w/ expired session', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() - 1000)
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
commentId: 'comment-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,66 @@
import { err, ok } from 'true-myth/result'
import { AuthPolicy } from '../../../domain/policies.js'
import {
CommentContext,
MaybeUserContext,
ProjectContext
} from '../../../domain/context.js'
import { Loaders } from '../../../domain/loaders.js'
import {
CommentNoAccessError,
CommentNotFoundError,
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { canCreateProjectCommentPolicy } from './canCreate.js'
export const canEditProjectCommentPolicy: AuthPolicy<
| typeof Loaders.getServerRole
| typeof Loaders.getComment
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext & CommentContext & ProjectContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof WorkspaceSsoSessionNoAccessError
| typeof CommentNotFoundError
| typeof CommentNoAccessError
>
> =
(loaders) =>
async ({ userId, commentId, projectId }) => {
// Includes canCreate check
const canCreate = await canCreateProjectCommentPolicy(loaders)({
userId,
projectId
})
if (canCreate.isErr) {
return err(canCreate.error)
}
// Check that comment exists
const comment = await loaders.getComment({ commentId, projectId })
if (!comment) return err(new CommentNotFoundError())
// Disallow if user is not the author
if (comment.authorId !== userId) {
return err(
new CommentNoAccessError('You do not have access to edit this comment')
)
}
return ok()
}
@@ -1,32 +1,31 @@
import cryptoRandomString from 'crypto-random-string'
import { assert, describe, expect, it } from 'vitest'
import { canCreateModelPolicy } from './canCreateModel.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { Roles } from '../../../core/constants.js'
import { Workspace } from '../../domain/workspaces/types.js'
import { WorkspacePlan } from '../../../workspaces/index.js'
import { Project } from '../../domain/projects/types.js'
import { canCreateModelPolicy } from './canCreate.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { Roles } from '../../../../core/constants.js'
import { Workspace } from '../../../domain/workspaces/types.js'
import { WorkspacePlan } from '../../../../workspaces/index.js'
import { Project } from '../../../domain/projects/types.js'
import {
ProjectNoAccessError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceLimitsReachedError,
WorkspaceNoAccessError
} from '../../domain/authErrors.js'
} from '../../../domain/authErrors.js'
import { getProjectFake } from '../../../../tests/fakes.js'
const buildCanCreateModelPolicy = (
overrides?: Partial<Parameters<typeof canCreateModelPolicy>[0]>
) =>
canCreateModelPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: async () => {
return {
id: cryptoRandomString({ length: 9 }),
isPublic: false,
isDiscoverable: false,
workspaceId: cryptoRandomString({ length: 9 })
}
},
getProject: getProjectFake({
id: cryptoRandomString({ length: 9 }),
isPublic: false,
isDiscoverable: false,
workspaceId: cryptoRandomString({ length: 9 })
}),
getProjectRole: async () => {
return Roles.Stream.Contributor
},
@@ -0,0 +1,71 @@
import { err, ok } from 'true-myth/result'
import {
ProjectNotFoundError,
ProjectNoAccessError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError,
WorkspaceLimitsReachedError,
ServerNoSessionError,
ServerNoAccessError,
WorkspaceReadOnlyError
} from '../../../domain/authErrors.js'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../../domain/loaders.js'
import { AuthPolicy } from '../../../domain/policies.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { ensureModelCanBeCreatedFragment } from '../../../fragments/workspaces.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getProject
| typeof AuthCheckContextLoaderKeys.getProjectRole
| typeof AuthCheckContextLoaderKeys.getServerRole
| typeof AuthCheckContextLoaderKeys.getWorkspace
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceLimits
| typeof AuthCheckContextLoaderKeys.getWorkspaceModelCount
type PolicyArgs = MaybeUserContext & ProjectContext
type PolicyErrors =
| InstanceType<typeof ProjectNotFoundError>
| InstanceType<typeof ProjectNoAccessError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspaceReadOnlyError>
| InstanceType<typeof WorkspaceLimitsReachedError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNoAccessError>
export const canCreateModelPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, projectId }) => {
// Ensure general write access
const ensureWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId
})
if (ensureWriteAccess.isErr) {
return err(ensureWriteAccess.error)
}
// Ensure (workspace?) accepts models
const ensuredModelsAccepted = await ensureModelCanBeCreatedFragment(loaders)({
projectId,
userId
})
if (ensuredModelsAccepted.isErr) {
return err(ensuredModelsAccepted.error)
}
return ok()
}
@@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getModelFake, getProjectFake } from '../../../../tests/fakes.js'
import {
ModelNotFoundError,
ProjectNoAccessError,
ProjectNotFoundError,
ReservedModelNotDeletableError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { canDeleteModelPolicy } from './canDelete.js'
const buildSUT = (overrides?: Partial<Parameters<typeof canDeleteModelPolicy>[0]>) =>
canDeleteModelPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getModel: getModelFake({
id: 'model-id',
projectId: 'project-id',
authorId: 'user-id',
name: 'model-name'
}),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: Partial<Parameters<typeof canDeleteModelPolicy>[0]>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
describe('canDeleteModelPolicy', () => {
it('returns error if user is not logged in', async () => {
const sut = buildSUT()
const result = await sut({
userId: undefined,
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('returns error if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('returns error if project not found', async () => {
const sut = buildSUT({
getProject: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('returns error if model not found', async () => {
const sut = buildSUT({
getModel: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: ModelNotFoundError.code
})
})
it('returns error if model is reserved', async () => {
const sut = buildSUT({
getModel: getModelFake({
id: 'model-id',
projectId: 'project-id',
name: 'main',
authorId: 'user-id'
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'main'
})
expect(result).toBeAuthErrorResult({
code: ReservedModelNotDeletableError.code
})
})
it('returns error if user is not author and not project owner', async () => {
const sut = buildSUT({
getModel: getModelFake({
id: 'model-id',
projectId: 'project-id',
authorId: 'other-user-id'
}),
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns error if not at least contributor', async () => {
const sut = buildSUT({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns ok if permissible', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthOKResult()
})
it('returns ok if not author, but project owner', async () => {
const sut = buildSUT({
getModel: getModelFake({
id: 'model-id',
projectId: 'project-id',
authorId: 'other-user-id'
}),
getProjectRole: async () => Roles.Stream.Owner
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthOKResult()
})
it('returns ok if no author, but project owner', async () => {
const sut = buildSUT({
getModel: getModelFake({
id: 'model-id',
projectId: 'project-id',
authorId: null
}),
getProjectRole: async () => Roles.Stream.Owner
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthOKResult()
})
describe('with workspace project', () => {
it('returns ok if permissible', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthOKResult()
})
it('returns ok with implicit owner role', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Admin,
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if invalid workspace and project role', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns ok if no sso configured', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no sso session', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('returns error if sso expired', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(new Date().getTime() - 1000)
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
modelId: 'model-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,93 @@
import { err, ok } from 'true-myth/result'
import {
MaybeUserContext,
ModelContext,
ProjectContext
} from '../../../domain/context.js'
import { AuthPolicy } from '../../../domain/policies.js'
import {
ensureImplicitProjectMemberWithWriteAccessFragment,
ensureMinimumProjectRoleFragment
} from '../../../fragments/projects.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ReservedModelNotDeletableError,
ModelNotFoundError,
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { Roles } from '../../../../core/constants.js'
export const canDeleteModelPolicy: AuthPolicy<
| typeof Loaders.getModel
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
ProjectContext & MaybeUserContext & ModelContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof WorkspaceSsoSessionNoAccessError
| typeof ModelNotFoundError
| typeof ReservedModelNotDeletableError
>
> =
(loaders) =>
async ({ userId, projectId, modelId }) => {
// Ensure general project write access
const ensureWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId
})
if (ensureWriteAccess.isErr) {
return err(ensureWriteAccess.error)
}
// Ensure 'main'/'globals' doesn't get deleted
const model = await loaders.getModel({
projectId,
modelId
})
if (!model) {
return err(new ModelNotFoundError())
}
// Model must be owned by author OR user must be project owner
if (!model.authorId || model.authorId !== userId) {
const ensureProjectOwner = await ensureMinimumProjectRoleFragment(loaders)({
userId: userId!,
projectId,
role: Roles.Stream.Owner
})
if (ensureProjectOwner.isErr) {
return err(ensureProjectOwner.error)
}
}
if (model.name === 'main') {
return err(
new ReservedModelNotDeletableError("The 'main' model cannot be deleted")
)
}
if (model.name === 'globals') {
return err(
new ReservedModelNotDeletableError("The 'globals' model cannot be deleted")
)
}
return ok()
}
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import { Roles } from '../../../../core/constants.js'
import { parseFeatureFlags } from '../../../../environment/index.js'
import { getProjectFake } from '../../../../tests/fakes.js'
import { canUpdateModelPolicy } from './canUpdate.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
const buildSUT = (overrides?: Partial<Parameters<typeof canUpdateModelPolicy>[0]>) =>
canUpdateModelPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => Roles.Stream.Contributor,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: Partial<Parameters<typeof canUpdateModelPolicy>[0]>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date()
}),
...overrides
})
describe('canUpdateProject', () => {
it('returns error if user is not logged in', async () => {
const sut = buildSUT()
const result = await sut({
userId: undefined,
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoSessionError.code
})
})
it('returns error if user not found', async () => {
const sut = buildSUT({
getServerRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('returns error if project not found', async () => {
const sut = buildSUT({
getProject: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotFoundError.code
})
})
it('returns error if not at least contributor', async () => {
const sut = buildSUT({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns ok if permissible', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
describe('with workspace project', () => {
it('returns ok if permissible', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns ok with implicit owner role', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Admin,
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if invalid workspace and project role', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceRole: async () => Roles.Workspace.Member,
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
})
})
it('returns ok if no sso configured', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('returns error if no sso session', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
it('returns error if sso expired', async () => {
const sut = buildWorkspaceSUT({
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(new Date().getTime() - 1000)
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceSsoSessionNoAccessError.code
})
})
})
})
@@ -0,0 +1,48 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { AuthPolicy } from '../../../domain/policies.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../../fragments/projects.js'
import { Loaders } from '../../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
WorkspaceNoAccessError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
export const canUpdateModelPolicy: AuthPolicy<
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
ProjectContext & MaybeUserContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof WorkspaceSsoSessionNoAccessError
>
> =
(loaders) =>
async ({ userId, projectId }) => {
// Ensure general project write access
const ensureWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId
})
if (ensureWriteAccess.isErr) {
return err(ensureWriteAccess.error)
}
return ok()
}
@@ -13,17 +13,12 @@ import {
import { err, ok } from 'true-myth/result'
import { Roles } from '../../../core/constants.js'
import {
ensureWorkspaceProjectCanBeCreatedFragment,
ensureWorkspaceRoleAndSessionFragment,
ensureWorkspacesEnabledFragment
} from '../../fragments/workspaces.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
import {
isNewWorkspacePlan,
isWorkspacePlanStatusReadOnly
} from '../../../workspaces/index.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
export const canCreateWorkspaceProjectPolicy: AuthPolicy<
| 'getEnv'
@@ -68,47 +63,16 @@ export const canCreateWorkspaceProjectPolicy: AuthPolicy<
return err(ensuredWorkspaceAccess.error)
}
// guests cannot create projects in the workspace
const isNotGuest = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
// Ensure workspace accepts new projects
const ensuredProjectsAccepted = await ensureWorkspaceProjectCanBeCreatedFragment(
loaders
)({
workspaceId,
role: Roles.Workspace.Member
userId
})
if (!isNotGuest)
return err(
new WorkspaceNotEnoughPermissionsError({
message: 'Guests cannot create projects in the workspace'
})
)
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
if (isWorkspacePlanStatusReadOnly(workspacePlan.status))
return err(new WorkspaceReadOnlyError())
if (isNewWorkspacePlan(workspacePlan.name)) {
const isEditor = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isEditor) return err(new WorkspaceNoEditorSeatError())
if (ensuredProjectsAccepted.isErr) {
return err(ensuredProjectsAccepted.error)
}
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
if (!workspaceLimits) return err(new WorkspaceNoAccessError())
// no limits imposed
if (workspaceLimits.projectCount === null) return ok()
const currentProjectCount = await loaders.getWorkspaceProjectCount({
workspaceId
})
// this will not happen in practice
if (currentProjectCount === null) return err(new WorkspaceNoAccessError())
return currentProjectCount < workspaceLimits.projectCount
? ok()
: err(new WorkspaceLimitsReachedError({ payload: { limit: 'projectCount' } }))
return ok()
}
+23 -6
View File
@@ -1,20 +1,37 @@
import { merge } from 'lodash'
import { Project } from '../authz/domain/projects/types.js'
import { Comment } from '../authz/domain/comments/types.js'
import { nanoid } from 'nanoid'
import { Model } from '../authz/domain/models/types.js'
export const fakeGetFactory =
<T extends Record<string, unknown>>(defaults: T) =>
<T extends Record<string, unknown>>(defaults: () => T) =>
(overrides?: Partial<T>) =>
(): Promise<T> => {
if (overrides) {
return Promise.resolve(merge(defaults, overrides))
return Promise.resolve(merge({}, defaults(), overrides))
}
return Promise.resolve(defaults)
return Promise.resolve(defaults())
}
export const getProjectFake = fakeGetFactory<Project>({
export const getProjectFake = fakeGetFactory<Project>(() => ({
id: nanoid(10),
isPublic: false,
isDiscoverable: false,
workspaceId: null
})
workspaceId: null,
allowPublicComments: false
}))
export const getCommentFake = fakeGetFactory<Comment>(() => ({
id: nanoid(10),
authorId: nanoid(10),
projectId: nanoid(10)
}))
export const getModelFake = fakeGetFactory<Model>(() => ({
id: nanoid(10),
projectId: nanoid(10),
name: nanoid(20),
authorId: nanoid(10)
}))
@@ -146,6 +146,31 @@ export const isSelfServeAvailablePlan = (plan: WorkspacePlans): boolean => {
}
}
export const isPaidPlan = (plan: WorkspacePlans): boolean => {
switch (plan) {
case 'team':
case 'teamUnlimited':
case 'pro':
case 'proUnlimited':
return true
case 'free':
case 'starter':
case 'plus':
case 'business':
case 'starterInvoiced':
case 'plusInvoiced':
case 'businessInvoiced':
case 'teamUnlimitedInvoiced':
case 'proUnlimitedInvoiced':
case 'unlimited':
case 'academia':
return false
default:
throwUncoveredError(plan)
}
}
/**
* BILLING INTERVALS
*/