chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,669 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq, sql } from 'drizzle-orm'
|
||||
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import {
|
||||
getAllowedIntegrationsFromEnv,
|
||||
isAccessControlEnabled,
|
||||
isHosted,
|
||||
isInvitationsDisabled,
|
||||
isPublicApiDisabled,
|
||||
} from '@/lib/core/config/env-flags'
|
||||
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('PermissionCheck')
|
||||
|
||||
export class ProviderNotAllowedError extends Error {
|
||||
constructor(providerId: string, model: string) {
|
||||
super(
|
||||
`Provider "${providerId}" is not allowed for model "${model}" based on your permission group settings`
|
||||
)
|
||||
this.name = 'ProviderNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelNotAllowedError extends Error {
|
||||
constructor(model: string) {
|
||||
super(`Model "${model}" is not allowed based on your permission group settings`)
|
||||
this.name = 'ModelNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class IntegrationNotAllowedError extends Error {
|
||||
constructor(blockType: string, reason?: string) {
|
||||
super(
|
||||
reason
|
||||
? `Integration "${blockType}" is not allowed: ${reason}`
|
||||
: `Integration "${blockType}" is not allowed based on your permission group settings`
|
||||
)
|
||||
this.name = 'IntegrationNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolNotAllowedError extends Error {
|
||||
constructor(toolId: string) {
|
||||
super(`Tool "${toolId}" is not allowed based on your permission group settings`)
|
||||
this.name = 'ToolNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class McpToolsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('MCP tools are not allowed based on your permission group settings')
|
||||
this.name = 'McpToolsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomToolsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Custom tools are not allowed based on your permission group settings')
|
||||
this.name = 'CustomToolsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class SkillsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Skills are not allowed based on your permission group settings')
|
||||
this.name = 'SkillsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvitationsNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Invitations are not allowed based on your permission group settings')
|
||||
this.name = 'InvitationsNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicApiNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Public API access is not allowed based on your permission group settings')
|
||||
this.name = 'PublicApiNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicFileSharingNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Public file sharing is not allowed based on your permission group settings')
|
||||
this.name = 'PublicFileSharingNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the env allowlist into a permission config.
|
||||
* If `config` is null and no env allowlist is set, returns null.
|
||||
* If `config` is null but env allowlist is set, returns a default config with only allowedIntegrations set.
|
||||
* If both are set, intersects the two allowlists.
|
||||
*/
|
||||
function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null {
|
||||
const envAllowlist = getAllowedIntegrationsFromEnv()
|
||||
|
||||
if (envAllowlist === null) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (config === null) {
|
||||
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: envAllowlist }
|
||||
}
|
||||
|
||||
const merged =
|
||||
config.allowedIntegrations === null
|
||||
? envAllowlist
|
||||
: config.allowedIntegrations
|
||||
.map((i) => i.toLowerCase())
|
||||
.filter((i) => envAllowlist.includes(i))
|
||||
|
||||
return { ...config, allowedIntegrations: merged }
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission group that governs a user in a given context, with its parsed
|
||||
* config. Shared by the executor path and the `/api/permission-groups/user`
|
||||
* route so resolution never drifts between the two.
|
||||
*/
|
||||
export interface ResolvedPermissionGroup {
|
||||
permissionGroupId: string
|
||||
groupName: string
|
||||
config: PermissionGroupConfig
|
||||
}
|
||||
|
||||
/** The organization's single default group (`isDefault`), or `null`. */
|
||||
async function resolveDefaultGroup(
|
||||
organizationId: string
|
||||
): Promise<ResolvedPermissionGroup | null> {
|
||||
const [defaultGroup] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
config: permissionGroup.config,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, true))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!defaultGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
permissionGroupId: defaultGroup.id,
|
||||
groupName: defaultGroup.name,
|
||||
config: parsePermissionGroupConfig(defaultGroup.config),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the group governing `userId` in `workspaceId` (which belongs to
|
||||
* `organizationId`). One effective group per workspace, by precedence:
|
||||
* 1. a non-default group targeting this workspace that `userId` is an explicit
|
||||
* member of, else
|
||||
* 2. a non-default group targeting this workspace that has no explicit members
|
||||
* — governs all members of the workspace, including external members, else
|
||||
* 3. the organization's default group (also governs external members), else
|
||||
* 4. `null` (unrestricted).
|
||||
*
|
||||
* Assignment-time conflict checks keep this unambiguous: at most one all-members
|
||||
* group per workspace, and a user is an explicit member of at most one group per
|
||||
* workspace. If an overlap nonetheless exists, the oldest group wins — rows are
|
||||
* ordered by `created_at` (then `id`).
|
||||
*
|
||||
* Callers gate on enterprise entitlement before invoking this and merge the env
|
||||
* allowlist afterwards.
|
||||
*/
|
||||
export async function resolveWorkspaceGroup(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
workspaceId: string
|
||||
): Promise<ResolvedPermissionGroup | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
config: permissionGroup.config,
|
||||
isMember: sql<boolean>`exists (
|
||||
select 1 from ${permissionGroupMember}
|
||||
where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id}
|
||||
and ${permissionGroupMember.userId} = ${userId}
|
||||
)`,
|
||||
hasMembers: sql<boolean>`exists (
|
||||
select 1 from ${permissionGroupMember}
|
||||
where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id}
|
||||
)`,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.innerJoin(
|
||||
permissionGroupWorkspace,
|
||||
and(
|
||||
eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id),
|
||||
eq(permissionGroupWorkspace.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.isDefault, false))
|
||||
)
|
||||
.orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id))
|
||||
|
||||
const winner = rows.find((row) => row.isMember) ?? rows.find((row) => !row.hasMembers)
|
||||
|
||||
if (winner) {
|
||||
return {
|
||||
permissionGroupId: winner.id,
|
||||
groupName: winner.name,
|
||||
config: parsePermissionGroupConfig(winner.config),
|
||||
}
|
||||
}
|
||||
|
||||
return resolveDefaultGroup(organizationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective permission-group config for a user in the context of a
|
||||
* specific workspace. The workspace is mapped to its organization and the
|
||||
* governing group is resolved with specific-over-all precedence.
|
||||
*
|
||||
* Returns `null` (after env merge) when the workspace has no organization, the
|
||||
* organization isn't on an enterprise plan, or no group governs the user.
|
||||
*
|
||||
* The env-level integration allowlist is always merged last so self-hosted
|
||||
* deployments can constrain integrations without touching the DB.
|
||||
*/
|
||||
export async function getUserPermissionConfig(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!isHosted && !isAccessControlEnabled) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
|
||||
if (!ws?.organizationId) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(ws.organizationId)
|
||||
if (!isEnterprise) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const resolved = await resolveWorkspaceGroup(userId, ws.organizationId, workspaceId)
|
||||
return mergeEnvAllowlist(resolved?.config ?? null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws {@link PublicFileSharingNotAllowedError} if the user's effective permission
|
||||
* group for the workspace disables public file sharing, or — when `authType` is
|
||||
* given — if that auth mode isn't in the group's `allowedFileShareAuthTypes`
|
||||
* allow-list (`null` allows all). No-op when access control doesn't apply
|
||||
* (non-enterprise / disabled), so non-governed orgs are unaffected.
|
||||
*/
|
||||
export async function validatePublicFileSharing(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
authType?: ShareAuthType
|
||||
): Promise<void> {
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
if (config.disablePublicFileSharing) {
|
||||
throw new PublicFileSharingNotAllowedError()
|
||||
}
|
||||
if (
|
||||
authType &&
|
||||
config.allowedFileShareAuthTypes !== null &&
|
||||
!config.allowedFileShareAuthTypes.includes(authType)
|
||||
) {
|
||||
logger.warn('File share auth type blocked by permission group', {
|
||||
userId,
|
||||
workspaceId,
|
||||
authType,
|
||||
})
|
||||
throw new PublicFileSharingNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Org-addressed variant of {@link getUserPermissionConfig}. Use when only the
|
||||
* organization is known (e.g. organization-level invitations). Non-default
|
||||
* groups target specific workspaces and never gate organization-level actions,
|
||||
* so this resolves the organization's default group — which governs everyone not
|
||||
* covered by a workspace group.
|
||||
*/
|
||||
export async function getUserPermissionConfigForOrganization(
|
||||
organizationId: string
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!isHosted && !isAccessControlEnabled) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!isEnterprise) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
const resolved = await resolveDefaultGroup(organizationId)
|
||||
return mergeEnvAllowlist(resolved?.config ?? null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-aware wrapper around `getUserPermissionConfig`. When an
|
||||
* `ExecutionContext` is provided, the resolved config is memoized on the
|
||||
* context so repeated checks during a single workflow run share one DB hit.
|
||||
*/
|
||||
async function getPermissionConfig(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<PermissionGroupConfig | null> {
|
||||
if (!userId || !workspaceId) {
|
||||
return mergeEnvAllowlist(null)
|
||||
}
|
||||
|
||||
if (ctx) {
|
||||
if (ctx.permissionConfigLoaded) {
|
||||
return ctx.permissionConfig ?? null
|
||||
}
|
||||
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
ctx.permissionConfig = config
|
||||
ctx.permissionConfigLoaded = true
|
||||
return config
|
||||
}
|
||||
|
||||
return getUserPermissionConfig(userId, workspaceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `model` appears in the group's model denylist. Comparison is
|
||||
* case-insensitive to match the normalization applied by `getProviderFromModel`.
|
||||
*/
|
||||
function isModelDenied(config: PermissionGroupConfig, model: string): boolean {
|
||||
if (!config.deniedModels || config.deniedModels.length === 0) {
|
||||
return false
|
||||
}
|
||||
const normalized = model.toLowerCase()
|
||||
return config.deniedModels.some((denied) => denied.toLowerCase() === normalized)
|
||||
}
|
||||
|
||||
export async function validateModelProvider(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
model: string,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, workspaceId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.allowedModelProviders !== null) {
|
||||
const providerId = getProviderFromModel(model)
|
||||
|
||||
if (!config.allowedModelProviders.includes(providerId)) {
|
||||
logger.warn('Model provider blocked by permission group', {
|
||||
userId,
|
||||
workspaceId,
|
||||
model,
|
||||
providerId,
|
||||
})
|
||||
throw new ProviderNotAllowedError(providerId, model)
|
||||
}
|
||||
}
|
||||
|
||||
if (isModelDenied(config, model)) {
|
||||
logger.warn('Model blocked by permission group', { userId, workspaceId, model })
|
||||
throw new ModelNotAllowedError(model)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateBlockType(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
blockType: string,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (isBlockTypeAccessControlExempt(blockType)) {
|
||||
return
|
||||
}
|
||||
|
||||
const config =
|
||||
userId && workspaceId
|
||||
? await getPermissionConfig(userId, workspaceId, ctx)
|
||||
: mergeEnvAllowlist(null)
|
||||
|
||||
if (!config || config.allowedIntegrations === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!config.allowedIntegrations.includes(blockType.toLowerCase())) {
|
||||
const envAllowlist = getAllowedIntegrationsFromEnv()
|
||||
const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase())
|
||||
logger.warn(
|
||||
blockedByEnv
|
||||
? 'Integration blocked by env allowlist'
|
||||
: 'Integration blocked by permission group',
|
||||
{ userId, workspaceId, blockType }
|
||||
)
|
||||
throw new IntegrationNotAllowedError(
|
||||
blockType,
|
||||
blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateMcpToolsAllowed(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, workspaceId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disableMcpTools) {
|
||||
logger.warn('MCP tools blocked by permission group', { userId, workspaceId })
|
||||
throw new McpToolsNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateCustomToolsAllowed(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, workspaceId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disableCustomTools) {
|
||||
logger.warn('Custom tools blocked by permission group', { userId, workspaceId })
|
||||
throw new CustomToolsNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateSkillsAllowed(
|
||||
userId: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
ctx?: ExecutionContext
|
||||
): Promise<void> {
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, workspaceId, ctx)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disableSkills) {
|
||||
logger.warn('Skills blocked by permission group', { userId, workspaceId })
|
||||
throw new SkillsNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the user is allowed to send invitations. Pass one of:
|
||||
* - `workspaceId` — workspace-scoped invite: block when the user's governing group (explicit or
|
||||
* org default) for the workspace's organization has `disableInvitations`.
|
||||
* - `organizationId` — organization-level invite (no specific workspace target): block when the
|
||||
* user's group in that organization (explicit or the org default) has `disableInvitations`.
|
||||
* - neither — only the global feature flag is checked.
|
||||
*/
|
||||
export async function validateInvitationsAllowed(
|
||||
userId: string | undefined,
|
||||
scope: string | { workspaceId?: string; organizationId?: string } = {}
|
||||
): Promise<void> {
|
||||
if (isInvitationsDisabled) {
|
||||
logger.warn('Invitations blocked by feature flag')
|
||||
throw new InvitationsNotAllowedError()
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const { workspaceId, organizationId } =
|
||||
typeof scope === 'string' ? { workspaceId: scope, organizationId: undefined } : scope
|
||||
|
||||
if (workspaceId) {
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
if (config?.disableInvitations) {
|
||||
logger.warn('Invitations blocked by permission group', { userId, workspaceId })
|
||||
throw new InvitationsNotAllowedError()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
const config = await getUserPermissionConfigForOrganization(organizationId)
|
||||
if (config?.disableInvitations) {
|
||||
logger.warn('Invitations blocked by permission group (organization-wide)', {
|
||||
userId,
|
||||
organizationId,
|
||||
})
|
||||
throw new InvitationsNotAllowedError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the user is allowed to enable public API access on the given
|
||||
* workspace. Also checks the global feature flag. When `workspaceId` is
|
||||
* omitted only the feature-flag check runs (no permission-group gate).
|
||||
*/
|
||||
export async function validatePublicApiAllowed(
|
||||
userId: string | undefined,
|
||||
workspaceId?: string
|
||||
): Promise<void> {
|
||||
if (isPublicApiDisabled) {
|
||||
logger.warn('Public API blocked by feature flag')
|
||||
throw new PublicApiNotAllowedError()
|
||||
}
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getUserPermissionConfig(userId, workspaceId)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disablePublicApi) {
|
||||
logger.warn('Public API blocked by permission group', { userId, workspaceId })
|
||||
throw new PublicApiNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolKind = 'mcp' | 'custom' | 'skill'
|
||||
|
||||
interface PermissionAssertion {
|
||||
userId: string | undefined
|
||||
workspaceId: string | undefined
|
||||
model?: string
|
||||
blockType?: string
|
||||
/**
|
||||
* Concrete tool ID being executed (e.g. `slack_canvas`). Checked against the
|
||||
* group's `deniedTools` denylist so an admin can allow an integration but deny
|
||||
* specific operations within it. Pass the normalized tool id.
|
||||
*/
|
||||
toolId?: string
|
||||
toolKind?: ToolKind
|
||||
ctx?: ExecutionContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry point for workspace-scoped access control. Loads the user's
|
||||
* permission config for `workspaceId` once and runs every applicable gate
|
||||
* (model provider, block type, tool kind) against it, throwing the existing
|
||||
* granular error classes on the first mismatch.
|
||||
*
|
||||
* Prefer this over calling the individual `validate*Allowed` helpers when
|
||||
* gating a shared entry point like `executeTool` or an HTTP proxy, so a single
|
||||
* callsite covers every future config field.
|
||||
*/
|
||||
export async function assertPermissionsAllowed(req: PermissionAssertion): Promise<void> {
|
||||
const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req
|
||||
|
||||
const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false
|
||||
|
||||
if (blockTypeExempt && !model && !toolKind && !toolId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config =
|
||||
userId && workspaceId
|
||||
? await getPermissionConfig(userId, workspaceId, ctx)
|
||||
: mergeEnvAllowlist(null)
|
||||
|
||||
if (model && config) {
|
||||
if (config.allowedModelProviders !== null) {
|
||||
const providerId = getProviderFromModel(model)
|
||||
if (!config.allowedModelProviders.includes(providerId)) {
|
||||
logger.warn('Model provider blocked by permission group', {
|
||||
userId,
|
||||
workspaceId,
|
||||
model,
|
||||
providerId,
|
||||
})
|
||||
throw new ProviderNotAllowedError(providerId, model)
|
||||
}
|
||||
}
|
||||
|
||||
if (isModelDenied(config, model)) {
|
||||
logger.warn('Model blocked by permission group', { userId, workspaceId, model })
|
||||
throw new ModelNotAllowedError(model)
|
||||
}
|
||||
}
|
||||
|
||||
if (blockType && !blockTypeExempt) {
|
||||
if (config && config.allowedIntegrations !== null) {
|
||||
if (!config.allowedIntegrations.includes(blockType.toLowerCase())) {
|
||||
const envAllowlist = getAllowedIntegrationsFromEnv()
|
||||
const blockedByEnv =
|
||||
envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase())
|
||||
logger.warn(
|
||||
blockedByEnv
|
||||
? 'Integration blocked by env allowlist'
|
||||
: 'Integration blocked by permission group',
|
||||
{ userId, workspaceId, blockType }
|
||||
)
|
||||
throw new IntegrationNotAllowedError(
|
||||
blockType,
|
||||
blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolId && config?.deniedTools?.includes(toolId)) {
|
||||
logger.warn('Tool blocked by permission group', { userId, workspaceId, toolId })
|
||||
throw new ToolNotAllowedError(toolId)
|
||||
}
|
||||
|
||||
if (toolKind && config) {
|
||||
if (toolKind === 'mcp' && config.disableMcpTools) {
|
||||
logger.warn('MCP tools blocked by permission group', { userId, workspaceId })
|
||||
throw new McpToolsNotAllowedError()
|
||||
}
|
||||
if (toolKind === 'custom' && config.disableCustomTools) {
|
||||
logger.warn('Custom tools blocked by permission group', { userId, workspaceId })
|
||||
throw new CustomToolsNotAllowedError()
|
||||
}
|
||||
if (toolKind === 'skill' && config.disableSkills) {
|
||||
logger.warn('Skills blocked by permission group', { userId, workspaceId })
|
||||
throw new SkillsNotAllowedError()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user