Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions apps/sim/app/api/permission-groups/user/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { getSession } from '@/lib/auth'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parsePermissionGroupConfig } from '@/lib/permission-groups/types'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import {
checkWorkspaceAccess,
isOrganizationAdminOrOwner,
} from '@/lib/workspaces/permissions/utils'

export const GET = withRouteHandler(async (req: Request) => {
const session = await getSession()
Expand All @@ -32,12 +35,33 @@ export const GET = withRouteHandler(async (req: Request) => {
}

const organizationId = access.workspace?.organizationId ?? null
if (!organizationId || !(await isOrganizationOnEnterprisePlan(organizationId))) {

// Workspaces without an organization have no permission groups, and the caller
// can never be an org admin in that case.
if (!organizationId) {
return NextResponse.json({
permissionGroupId: null,
groupName: null,
config: null,
entitled: false,
organizationId: null,
isOrgAdmin: false,
})
}

// Resolve role + entitlement against the WORKSPACE's owning organization (not
// the caller's active org) so management gating is scoped to the org that
// actually governs this workspace. External members are not org admins here.
const isOrgAdmin = await isOrganizationAdminOrOwner(session.user.id, organizationId)

if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
return NextResponse.json({
permissionGroupId: null,
groupName: null,
config: null,
entitled: false,
organizationId,
isOrgAdmin,
})
}

Expand Down Expand Up @@ -80,6 +104,8 @@ export const GET = withRouteHandler(async (req: Request) => {
groupName: null,
config: null,
entitled: true,
organizationId,
isOrgAdmin,
})
}

Expand All @@ -88,5 +114,7 @@ export const GET = withRouteHandler(async (req: Request) => {
groupName: resolved.groupName,
config: parsePermissionGroupConfig(resolved.config),
entitled: true,
organizationId,
isOrgAdmin,
})
})
9 changes: 8 additions & 1 deletion apps/sim/app/api/v1/admin/access-control/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
Expand Down Expand Up @@ -133,6 +134,12 @@ export const DELETE = withRouteHandler(
if (!parsed.success) return parsed.response

const { organizationId, reason: rawReason } = parsed.data.query
// The contract's refine guarantees this at the boundary; this explicit guard
// narrows the type (avoiding a non-null assertion) and stays correct even if
// the contract changes.
if (!organizationId) {
return badRequestResponse('organizationId is required')
}
const reason = rawReason || 'Enterprise plan churn cleanup'

try {
Expand All @@ -142,7 +149,7 @@ export const DELETE = withRouteHandler(
name: permissionGroup.name,
})
.from(permissionGroup)
.where(eq(permissionGroup.organizationId, organizationId!))
.where(eq(permissionGroup.organizationId, organizationId))

if (existingGroups.length === 0) {
logger.info('Admin API: No permission groups to delete', { organizationId })
Expand Down
32 changes: 17 additions & 15 deletions apps/sim/ee/access-control/components/access-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,10 @@ import {
Switch,
} from '@/components/emcn'
import { ArrowLeft } from '@/components/emcn/icons'
import { useSession } from '@/lib/auth/auth-client'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { cn } from '@/lib/core/utils/cn'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization'
import { getAllBlocks } from '@/blocks'
import {
type PermissionGroup,
Expand All @@ -44,7 +42,7 @@ import {
useUserPermissionConfig,
} from '@/ee/access-control/hooks/permission-groups'
import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers'
import { useOrganizationRoster, useOrganizations } from '@/hooks/queries/organization'
import { useOrganizationRoster } from '@/hooks/queries/organization'
import { useProviderModels } from '@/hooks/queries/providers'
import {
DYNAMIC_MODEL_PROVIDERS,
Expand Down Expand Up @@ -407,27 +405,31 @@ export function AccessControl() {
const params = useParams()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined

const { data: session } = useSession()
const { data: organizationsData, isPending: orgLoading } = useOrganizations()
const activeOrganization = organizationsData?.activeOrganization
const organizationId = activeOrganization?.id
// Access control is governed by the workspace's OWNING organization, which may
// differ from the caller's active org (e.g. external members). Resolve the org
// id and the caller's admin status server-side from the workspace so gating is
// never keyed off the session's active org.
const { data: userPermissionConfig, isPending: entitlementLoading } =
useUserPermissionConfig(workspaceId)
const organizationId = userPermissionConfig?.organizationId ?? undefined
const currentUserIsOrgAdmin = userPermissionConfig?.isOrgAdmin ?? false

// Group + roster reads require org admin/owner on the host org; only fetch them
// for admins to avoid surfacing expected 403s for non-admins/external members.
const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups(
organizationId,
!!organizationId
!!organizationId && currentUserIsOrgAdmin
)
const { data: roster } = useOrganizationRoster(organizationId)
const { data: userPermissionConfig, isPending: entitlementLoading } =
useUserPermissionConfig(workspaceId)

const userRole = getUserRole(activeOrganization, session?.user?.email ?? undefined)
const currentUserIsOrgAdmin = userRole === 'owner' || userRole === 'admin'
const { data: roster } = useOrganizationRoster(currentUserIsOrgAdmin ? organizationId : undefined)

const accessControlEnabledLocally = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
const isEntitled = accessControlEnabledLocally || !!userPermissionConfig?.entitled
const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId

const isLoading = !workspaceId || orgLoading || groupsLoading || entitlementLoading
const isLoading =
!workspaceId ||
entitlementLoading ||
(!!organizationId && currentUserIsOrgAdmin && groupsLoading)

const createPermissionGroup = useCreatePermissionGroup()
const updatePermissionGroup = useUpdatePermissionGroup()
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/api/contracts/permission-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export const userPermissionConfigSchema = z.object({
groupName: z.string().nullable(),
config: permissionGroupFullConfigSchema.nullable(),
entitled: z.boolean(),
/** The workspace's owning organization id (null when the workspace has no org). */
organizationId: z.string().nullable(),
/** Whether the caller is an owner/admin of the workspace's owning organization. */
isOrgAdmin: z.boolean(),
})
export type UserPermissionConfig = z.output<typeof userPermissionConfigSchema>

Expand Down
Loading