chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@sim/platform-authz",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.13",
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"exports": {
|
||||
"./predicates": {
|
||||
"types": "./src/predicates.ts",
|
||||
"default": "./src/predicates.ts"
|
||||
},
|
||||
"./workspace": {
|
||||
"types": "./src/workspace.ts",
|
||||
"default": "./src/workspace.ts"
|
||||
},
|
||||
"./workflow": {
|
||||
"types": "./src/workflow.ts",
|
||||
"default": "./src/workflow.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
"lint:check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@sim/db": "workspace:*",
|
||||
"drizzle-orm": "^0.45.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/node": "24.2.1",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { permissionTypeEnum } from '@sim/db/schema'
|
||||
|
||||
/** Workspace permission level: read < write < admin. */
|
||||
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
/** Total ordering of workspace permission levels: read < write < admin. */
|
||||
export const PERMISSION_RANK = { read: 1, write: 2, admin: 3 } as const satisfies Record<
|
||||
PermissionType,
|
||||
number
|
||||
>
|
||||
|
||||
/**
|
||||
* Whether an effective permission satisfies a required level under the
|
||||
* read < write < admin ordering. `null`/`undefined` (no access) never satisfies.
|
||||
* Single source of truth for permission-level comparisons across the app and the
|
||||
* realtime server — replaces the hand-written `=== 'admin' || === 'write'` ladders.
|
||||
*/
|
||||
export function permissionSatisfies(
|
||||
have: PermissionType | null | undefined,
|
||||
required: PermissionType
|
||||
): boolean {
|
||||
return have != null && PERMISSION_RANK[have] >= PERMISSION_RANK[required]
|
||||
}
|
||||
|
||||
/** Organization membership roles (Better Auth) that confer admin authority. */
|
||||
export const ORG_ADMIN_ROLES = ['owner', 'admin'] as const
|
||||
|
||||
/**
|
||||
* Whether an organization membership role is owner/admin. Owner/admin org roles
|
||||
* are derived workspace admins on the org's workspaces — single source of truth
|
||||
* for the `role === 'owner' || role === 'admin'` predicate, shared by server
|
||||
* resolvers and client UIs. Dependency-free (the only import is a type, which is
|
||||
* erased) so client bundles can import it without pulling in the DB client.
|
||||
*/
|
||||
export function isOrgAdminRole(role: string | null | undefined): boolean {
|
||||
return role === 'owner' || role === 'admin'
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { db, workflow, workflowFolder, workspace } from '@sim/db'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import {
|
||||
type PermissionType,
|
||||
permissionSatisfies,
|
||||
resolveEffectiveWorkspacePermission,
|
||||
} from './workspace'
|
||||
|
||||
export type { PermissionType }
|
||||
|
||||
export type ActiveWorkflowRecord = typeof workflow.$inferSelect
|
||||
|
||||
export interface ActiveWorkflowContext {
|
||||
workflow: ActiveWorkflowRecord
|
||||
workspaceId: string
|
||||
workspaceOrganizationId: string | null
|
||||
}
|
||||
|
||||
export async function getActiveWorkflowContext(
|
||||
workflowId: string
|
||||
): Promise<ActiveWorkflowContext | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
workflow,
|
||||
workspaceId: workspace.id,
|
||||
workspaceOrganizationId: workspace.organizationId,
|
||||
})
|
||||
.from(workflow)
|
||||
.innerJoin(workspace, eq(workflow.workspaceId, workspace.id))
|
||||
.where(
|
||||
and(eq(workflow.id, workflowId), isNull(workflow.archivedAt), isNull(workspace.archivedAt))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
workflow: rows[0].workflow,
|
||||
workspaceId: rows[0].workspaceId,
|
||||
workspaceOrganizationId: rows[0].workspaceOrganizationId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActiveWorkflowRecord(
|
||||
workflowId: string
|
||||
): Promise<ActiveWorkflowRecord | null> {
|
||||
const context = await getActiveWorkflowContext(workflowId)
|
||||
return context?.workflow ?? null
|
||||
}
|
||||
|
||||
export async function assertActiveWorkflowContext(
|
||||
workflowId: string
|
||||
): Promise<ActiveWorkflowContext> {
|
||||
const context = await getActiveWorkflowContext(workflowId)
|
||||
if (!context) {
|
||||
throw new Error(`Active workflow not found: ${workflowId}`)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type WorkflowRecord = typeof workflow.$inferSelect
|
||||
|
||||
export class WorkflowLockedError extends Error {
|
||||
readonly status = 423
|
||||
|
||||
constructor(message = 'Workflow is locked') {
|
||||
super(message)
|
||||
this.name = 'WorkflowLockedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class FolderLockedError extends Error {
|
||||
readonly status = 423
|
||||
|
||||
constructor(message = 'Folder is locked') {
|
||||
super(message)
|
||||
this.name = 'FolderLockedError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LockStatus {
|
||||
locked: boolean
|
||||
directLocked: boolean
|
||||
inheritedLocked: boolean
|
||||
lockedBy: 'workflow' | 'folder' | null
|
||||
lockedFolderId: string | null
|
||||
}
|
||||
|
||||
export async function getFolderLockStatus(folderId: string | null): Promise<LockStatus> {
|
||||
if (!folderId) {
|
||||
return {
|
||||
locked: false,
|
||||
directLocked: false,
|
||||
inheritedLocked: false,
|
||||
lockedBy: null,
|
||||
lockedFolderId: null,
|
||||
}
|
||||
}
|
||||
|
||||
let currentFolderId: string | null = folderId
|
||||
let isDirect = true
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentFolderId && !visited.has(currentFolderId)) {
|
||||
visited.add(currentFolderId)
|
||||
const [folder] = await db
|
||||
.select({
|
||||
id: workflowFolder.id,
|
||||
parentId: workflowFolder.parentId,
|
||||
locked: workflowFolder.locked,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.id, currentFolderId), isNull(workflowFolder.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!folder) break
|
||||
if (folder.locked) {
|
||||
return {
|
||||
locked: true,
|
||||
directLocked: isDirect,
|
||||
inheritedLocked: !isDirect,
|
||||
lockedBy: 'folder',
|
||||
lockedFolderId: folder.id,
|
||||
}
|
||||
}
|
||||
|
||||
currentFolderId = folder.parentId
|
||||
isDirect = false
|
||||
}
|
||||
|
||||
return {
|
||||
locked: false,
|
||||
directLocked: false,
|
||||
inheritedLocked: false,
|
||||
lockedBy: null,
|
||||
lockedFolderId: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkflowLockStatus(workflowId: string): Promise<LockStatus> {
|
||||
const [wf] = await db
|
||||
.select({
|
||||
locked: workflow.locked,
|
||||
folderId: workflow.folderId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!wf) {
|
||||
return {
|
||||
locked: false,
|
||||
directLocked: false,
|
||||
inheritedLocked: false,
|
||||
lockedBy: null,
|
||||
lockedFolderId: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (wf.locked) {
|
||||
return {
|
||||
locked: true,
|
||||
directLocked: true,
|
||||
inheritedLocked: false,
|
||||
lockedBy: 'workflow',
|
||||
lockedFolderId: null,
|
||||
}
|
||||
}
|
||||
|
||||
return getFolderLockStatus(wf.folderId)
|
||||
}
|
||||
|
||||
export async function assertWorkflowMutable(workflowId: string): Promise<void> {
|
||||
const status = await getWorkflowLockStatus(workflowId)
|
||||
if (status.locked) {
|
||||
throw new WorkflowLockedError(
|
||||
status.lockedBy === 'folder'
|
||||
? 'Workflow is locked by its containing folder'
|
||||
: 'Workflow is locked'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertFolderMutable(folderId: string | null): Promise<void> {
|
||||
const status = await getFolderLockStatus(folderId)
|
||||
if (status.locked) {
|
||||
throw new FolderLockedError(
|
||||
status.inheritedLocked ? 'Folder is locked by an ancestor folder' : 'Folder is locked'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class FolderNotFoundError extends Error {
|
||||
readonly status = 400
|
||||
|
||||
constructor(message = 'Target folder not found') {
|
||||
super(message)
|
||||
this.name = 'FolderNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves whether a folder may be assigned to a workflow in the given workspace:
|
||||
* it must exist, not be archived, and belong to that same workspace. A null/undefined
|
||||
* folderId (the workspace root) is always allowed. Guards against cross-workspace
|
||||
* folder references when a workflow's `folderId` is set from request input.
|
||||
*/
|
||||
export async function isFolderInWorkspace(
|
||||
folderId: string | null | undefined,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
if (!folderId) return true
|
||||
|
||||
const [folder] = await db
|
||||
.select({
|
||||
workspaceId: workflowFolder.workspaceId,
|
||||
archivedAt: workflowFolder.archivedAt,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, folderId))
|
||||
.limit(1)
|
||||
|
||||
return Boolean(folder && folder.workspaceId === workspaceId && !folder.archivedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws {@link FolderNotFoundError} (HTTP 400) when `folderId` does not belong to
|
||||
* `workspaceId` (or is archived/missing). No-op for a null/undefined folderId.
|
||||
*/
|
||||
export async function assertFolderInWorkspace(
|
||||
folderId: string | null | undefined,
|
||||
workspaceId: string
|
||||
): Promise<void> {
|
||||
if (!(await isFolderInWorkspace(folderId, workspaceId))) {
|
||||
throw new FolderNotFoundError()
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkflowWorkspaceAuthorizationResult {
|
||||
allowed: boolean
|
||||
status: number
|
||||
message?: string
|
||||
workflow: WorkflowRecord | null
|
||||
workspacePermission: PermissionType | null
|
||||
}
|
||||
|
||||
export async function authorizeWorkflowByWorkspacePermission(params: {
|
||||
workflowId: string
|
||||
userId: string
|
||||
action?: 'read' | 'write' | 'admin'
|
||||
}): Promise<WorkflowWorkspaceAuthorizationResult> {
|
||||
const { workflowId, userId, action = 'read' } = params
|
||||
|
||||
const activeContext = await getActiveWorkflowContext(workflowId)
|
||||
if (!activeContext) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 404,
|
||||
message: 'Workflow not found',
|
||||
workflow: null,
|
||||
workspacePermission: null,
|
||||
}
|
||||
}
|
||||
|
||||
const wf = activeContext.workflow
|
||||
|
||||
if (!wf.workspaceId) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message:
|
||||
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.',
|
||||
workflow: wf,
|
||||
workspacePermission: null,
|
||||
}
|
||||
}
|
||||
|
||||
const workspacePermission = await resolveEffectiveWorkspacePermission(
|
||||
userId,
|
||||
wf.workspaceId,
|
||||
activeContext.workspaceOrganizationId
|
||||
)
|
||||
|
||||
if (!permissionSatisfies(workspacePermission, action)) {
|
||||
return {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: `Unauthorized: Access denied to ${action} this workflow`,
|
||||
workflow: wf,
|
||||
workspacePermission,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: wf,
|
||||
workspacePermission,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissions } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { isOrgAdminRole, type PermissionType } from './predicates'
|
||||
|
||||
export * from './predicates'
|
||||
|
||||
/**
|
||||
* Resolves the effective workspace permission under the governance inheritance
|
||||
* model: the owners/admins of the organization that owns the workspace are
|
||||
* derived workspace admins. Returns the higher of any explicit grant and the
|
||||
* org-admin derivation.
|
||||
*
|
||||
* The workspace owner is intentionally NOT a special case: every owner already
|
||||
* holds an explicit `admin` row in `permissions` (added at creation, verified
|
||||
* across all production workspaces), so the lookup below already grants them
|
||||
* admin. `workspace.ownerId` is a lifecycle anchor, not a permission input.
|
||||
*
|
||||
* Single source of truth for workspace-permission resolution, shared by the Next
|
||||
* app (`getEffectiveWorkspacePermission`) and the realtime server (via the
|
||||
* `/workflow` entry). Lives in a package because `apps/realtime` needs it and
|
||||
* packages may not import app code.
|
||||
*/
|
||||
export async function resolveEffectiveWorkspacePermission(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
workspaceOrganizationId: string | null
|
||||
): Promise<PermissionType | null> {
|
||||
const [permissionRow] = await db
|
||||
.select({ permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const explicit = (permissionRow?.permissionType as PermissionType | undefined) ?? null
|
||||
|
||||
if (workspaceOrganizationId && explicit !== 'admin') {
|
||||
const [memberRow] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.userId, userId), eq(member.organizationId, workspaceOrganizationId)))
|
||||
.limit(1)
|
||||
if (isOrgAdminRole(memberRow?.role)) {
|
||||
return 'admin'
|
||||
}
|
||||
}
|
||||
|
||||
return explicit
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@sim/tsconfig/library.json",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user